feat: add wave 2 plugins and --cprofile CLI flag

Add 7 new pure-stdlib plugins: whois (raw TCP port 43), portcheck
(async TCP connect scan with internal-net guard), httpcheck (HTTP
status/redirects/timing), tlscheck (TLS version/cipher/cert inspect),
blacklist (parallel DNSBL check against 10 RBLs), rand (password/hex/
uuid/bytes/int/coin/dice), and timer (async countdown notifications).

Add --cprofile flag to CLI for profiling bot runtime. Update all docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 01:58:47 +01:00
parent c5b0430da8
commit 530f33be76
12 changed files with 960 additions and 12 deletions

View File

@@ -11,6 +11,8 @@
| P0 | [x] | Plugin help: !help <plugin> shows description + commands |
| P0 | [x] | Bot uptime command |
| P0 | [x] | Documentation update (all docs current) |
| P0 | [x] | Wave 2 plugins: whois, portcheck, httpcheck, tlscheck, blacklist, rand, timer |
| P0 | [x] | CLI --cprofile flag |
| P1 | [ ] | SASL PLAIN authentication |
| P1 | [ ] | Rate limiting for outgoing messages |
| P1 | [ ] | CTCP responses (VERSION, TIME, PING) |
@@ -20,6 +22,8 @@
| Date | Task |
|------|------|
| 2026-02-15 | Wave 2 plugins (whois, portcheck, httpcheck, tlscheck, blacklist, rand, timer) |
| 2026-02-15 | CLI --cprofile flag |
| 2026-02-15 | Wave 1 plugins (dns, encode, hash, defang, revshell, cidr) |
| 2026-02-15 | Hot-reload, shorthand, plugin help |
| 2026-02-15 | Container deployment (Containerfile, compose, Makefile targets) |

16
TODO.md
View File

@@ -1,14 +1,14 @@
# derp - Backlog
## Wave 2 Plugins (stdlib, next up)
## Wave 2 Plugins (stdlib) -- DONE
- [ ] `whois` -- raw socket WHOIS client (port 43)
- [ ] `portcheck` -- async TCP connect scan
- [ ] `httpcheck` -- HTTP status, redirects, response time
- [ ] `tlscheck` -- TLS version, cipher suite, cert chain, expiry
- [ ] `blacklist` -- DNSBL/RBL IP check
- [ ] `rand` -- passwords, hex strings, UUIDs
- [ ] `timer` -- countdown/stopwatch for time-boxed ops
- [x] `whois` -- raw socket WHOIS client (port 43)
- [x] `portcheck` -- async TCP connect scan
- [x] `httpcheck` -- HTTP status, redirects, response time
- [x] `tlscheck` -- TLS version, cipher suite, cert chain, expiry
- [x] `blacklist` -- DNSBL/RBL IP check
- [x] `rand` -- passwords, hex strings, UUIDs
- [x] `timer` -- countdown/stopwatch for time-boxed ops
## Wave 3 Plugins (local databases)

View File

@@ -10,6 +10,7 @@ make run # Start bot (bare metal)
make link # Symlink to ~/.local/bin
derp -c config.toml # Run with custom config
derp -v # Verbose/debug mode
derp --cprofile # Profile to derp.prof
```
## Container
@@ -50,6 +51,8 @@ make logs # Follow logs
!dns 1.2.3.4 # Reverse PTR lookup
!dns example.com MX # Specific type (A/AAAA/MX/NS/TXT/CNAME/PTR/SOA)
!cert example.com # CT log lookup (max 5 domains)
!whois example.com # WHOIS domain lookup
!whois 8.8.8.8 # WHOIS IP lookup
```
## Red Team
@@ -77,6 +80,35 @@ make logs # Follow logs
```
!cidr 10.0.0.0/24 # Subnet info
!cidr contains 10.0.0.0/8 10.1.2.3 # Membership check
!portcheck 10.0.0.1 # Scan common ports
!portcheck 10.0.0.1 22,80,443 # Scan specific ports
!httpcheck https://example.com # HTTP status + timing
!tlscheck example.com # TLS/cert inspection
!tlscheck 10.0.0.1 8443 # Custom port
!blacklist 1.2.3.4 # DNSBL reputation check
```
## Random
```
!rand password # 16-char random password
!rand password 32 all # 32-char, full charset
!rand hex 64 # Random hex string
!rand uuid # UUID4
!rand bytes 32 # Random bytes (hex)
!rand int 100 # Random 0..99
!rand coin # Heads or tails
!rand dice 2d20 # Roll 2x d20
```
## Timer
```
!timer 5m # 5-minute countdown
!timer 1h30m deploy # Named timer
!timer 90 # 90 seconds
!timer list # Show active timers
!timer cancel deploy # Cancel a timer
```
## Plugin Template

View File

@@ -16,6 +16,7 @@ derp --config /path/to/derp.toml --verbose
|------|-------------|
| `-c, --config PATH` | Config file path |
| `-v, --verbose` | Debug logging |
| `--cprofile [PATH]` | Enable cProfile, dump to PATH [derp.prof] |
| `-V, --version` | Print version |
| `-h, --help` | Show help |
@@ -68,6 +69,15 @@ level = "info" # Logging level: debug, info, warning, error
| `!revshell <type> <ip> <port>` | Generate reverse shell one-liner |
| `!cidr <network>` | Subnet info (range, hosts, mask) |
| `!cidr contains <net> <ip>` | Check if IP belongs to network |
| `!whois <domain\|ip>` | WHOIS lookup via raw TCP (port 43) |
| `!portcheck <host> [ports]` | Async TCP port scan (max 20 ports) |
| `!httpcheck <url>` | HTTP status, redirects, response time |
| `!tlscheck <host> [port]` | TLS version, cipher, cert details |
| `!blacklist <ip>` | Check IP against 10 DNSBLs |
| `!rand <mode> [args]` | Random: password, hex, uuid, bytes, int, coin, dice |
| `!timer <duration> [label]` | Set countdown timer with notification |
| `!timer list` | Show active timers |
| `!timer cancel <label>` | Cancel a running timer |
### Command Shorthand

132
plugins/blacklist.py Normal file
View File

@@ -0,0 +1,132 @@
"""Plugin: DNSBL/RBL IP reputation check (pure stdlib)."""
from __future__ import annotations
import asyncio
import ipaddress
import os
import socket
import struct
from derp.plugin import command
_DNSBLS = [
("zen.spamhaus.org", "Spamhaus"),
("bl.spamcop.net", "SpamCop"),
("b.barracudacentral.org", "Barracuda"),
("dnsbl.sorbs.net", "SORBS"),
("spam.dnsbl.sorbs.net", "SORBS-Spam"),
("cbl.abuseat.org", "CBL"),
("dnsbl-1.uceprotect.net", "UCEPROTECT-1"),
("psbl.surriel.com", "PSBL"),
("dyna.spamrats.com", "SpamRats"),
("all.s5h.net", "S5H"),
]
_TIMEOUT = 5.0
def _get_resolver() -> str:
"""Read first IPv4 nameserver from /etc/resolv.conf."""
try:
with open("/etc/resolv.conf") as f:
for line in f:
line = line.strip()
if line.startswith("nameserver"):
addr = line.split()[1]
try:
ipaddress.IPv4Address(addr)
return addr
except ValueError:
continue
except (OSError, IndexError):
pass
return "8.8.8.8"
def _build_a_query(name: str) -> bytes:
"""Build a minimal DNS A query."""
tid = os.urandom(2)
flags = struct.pack("!H", 0x0100)
counts = struct.pack("!HHHH", 1, 0, 0, 0)
encoded = b""
for label in name.rstrip(".").split("."):
encoded += bytes([len(label)]) + label.encode("ascii")
encoded += b"\x00"
return tid + flags + counts + encoded + struct.pack("!HH", 1, 1)
def _check_response(data: bytes) -> bool:
"""Check if DNS response has answer records (listed)."""
if len(data) < 12:
return False
_, flags, _, ancount = struct.unpack_from("!HHHH", data, 0)
rcode = flags & 0x0F
return rcode == 0 and ancount > 0
def _query_dnsbl(name: str, server: str) -> bool:
"""Blocking DNS A lookup, returns True if listed."""
query = _build_a_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(_TIMEOUT)
try:
sock.sendto(query, (server, 53))
data = sock.recv(512)
return _check_response(data)
except (socket.timeout, OSError):
return False
finally:
sock.close()
def _reversed_ip(addr: str) -> str:
"""Reverse an IPv4 address for DNSBL lookup."""
return ".".join(reversed(addr.split(".")))
async def _check_one(ip_rev: str, zone: str, label: str,
server: str) -> tuple[str, bool]:
"""Check one DNSBL asynchronously."""
name = f"{ip_rev}.{zone}"
loop = asyncio.get_running_loop()
listed = await loop.run_in_executor(None, _query_dnsbl, name, server)
return label, listed
@command("blacklist", help="DNSBL check: !blacklist <ip>")
async def cmd_blacklist(bot, message):
"""Check an IP against common DNSBL/RBL services.
Usage:
!blacklist 1.2.3.4
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !blacklist <ip>")
return
addr = parts[1]
try:
ip = ipaddress.IPv4Address(addr)
except ValueError:
await bot.reply(message, f"Invalid IPv4 address: {addr}")
return
if ip.is_private or ip.is_loopback:
await bot.reply(message, f"{addr}: private/loopback address, skipping DNSBL")
return
ip_rev = _reversed_ip(str(ip))
server = _get_resolver()
tasks = [_check_one(ip_rev, zone, label, server) for zone, label in _DNSBLS]
results = await asyncio.gather(*tasks)
listed = [label for label, hit in results if hit]
clean = [label for label, hit in results if not hit]
if listed:
await bot.reply(message, f"{addr} LISTED on: {', '.join(listed)} "
f"({len(clean)}/{len(results)} clean)")
else:
await bot.reply(message, f"{addr} clean on all {len(results)} DNSBLs")

109
plugins/httpcheck.py Normal file
View File

@@ -0,0 +1,109 @@
"""Plugin: HTTP status checker (pure stdlib, urllib)."""
from __future__ import annotations
import asyncio
import ssl
import time
import urllib.request
from derp.plugin import command
_TIMEOUT = 10
_MAX_REDIRECTS = 10
_USER_AGENT = "derp/1.0"
def _check(url: str) -> dict:
"""Blocking HTTP check. Returns dict with status info."""
result: dict = {
"url": url,
"status": 0,
"reason": "",
"time_ms": 0,
"redirects": [],
"server": "",
"content_type": "",
"error": "",
}
# Build opener that doesn't follow redirects automatically
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
result["redirects"].append((code, newurl))
if len(result["redirects"]) >= _MAX_REDIRECTS:
return None
return urllib.request.HTTPRedirectHandler.redirect_request(
self, req, fp, code, msg, headers, newurl,
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
opener = urllib.request.build_opener(
NoRedirect,
urllib.request.HTTPSHandler(context=ctx),
)
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", _USER_AGENT)
t0 = time.monotonic()
try:
resp = opener.open(req, timeout=_TIMEOUT)
result["time_ms"] = (time.monotonic() - t0) * 1000
result["status"] = resp.status
result["reason"] = resp.reason
result["server"] = resp.headers.get("Server", "")
result["content_type"] = resp.headers.get("Content-Type", "")
resp.close()
except urllib.error.HTTPError as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["status"] = exc.code
result["reason"] = exc.reason
except urllib.error.URLError as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["error"] = str(exc.reason)
except Exception as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["error"] = str(exc)
return result
@command("httpcheck", help="HTTP check: !httpcheck <url>")
async def cmd_httpcheck(bot, message):
"""Check HTTP status, redirects, and response time.
Usage:
!httpcheck https://example.com
!httpcheck http://10.0.0.1:8080
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !httpcheck <url>")
return
url = parts[1]
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, _check, url)
if result["error"]:
await bot.reply(message, f"{url} -> error: {result['error']} ({result['time_ms']:.0f}ms)")
return
parts_out = [f"{result['status']} {result['reason']}"]
parts_out.append(f"{result['time_ms']:.0f}ms")
if result["redirects"]:
chain = " -> ".join(f"{code} {loc}" for code, loc in result["redirects"])
parts_out.append(f"redirects: {chain}")
if result["server"]:
parts_out.append(f"server: {result['server']}")
await bot.reply(message, f"{url} -> {' | '.join(parts_out)}")

128
plugins/portcheck.py Normal file
View File

@@ -0,0 +1,128 @@
"""Plugin: async TCP port scanner (pure stdlib)."""
from __future__ import annotations
import asyncio
import ipaddress
import time
from derp.plugin import command
_TIMEOUT = 3.0
_MAX_PORTS = 20
_COMMON_PORTS = [
21, 22, 23, 25, 53, 80, 110, 111, 135, 139,
143, 443, 445, 993, 995, 1433, 1723, 3306,
3389, 5432, 5900, 6379, 8080, 8443, 27017,
]
_PORT_NAMES = {
21: "ftp", 22: "ssh", 23: "telnet", 25: "smtp", 53: "dns",
80: "http", 110: "pop3", 111: "rpc", 135: "msrpc", 139: "netbios",
143: "imap", 443: "https", 445: "smb", 993: "imaps", 995: "pop3s",
1433: "mssql", 1723: "pptp", 3306: "mysql", 3389: "rdp",
5432: "postgres", 5900: "vnc", 6379: "redis", 8080: "http-alt",
8443: "https-alt", 27017: "mongo",
}
def _is_internal(host: str) -> bool:
"""Check if a host resolves to a private/reserved address."""
try:
ip = ipaddress.ip_address(host)
return ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
except ValueError:
return False
def _validate_host(host: str) -> bool:
"""Check that host is an IP or looks like a domain."""
try:
ipaddress.ip_address(host)
return True
except ValueError:
pass
return "." in host and all(c.isalnum() or c in ".-" for c in host)
async def _check_port(host: str, port: int, timeout: float) -> tuple[int, bool, float]:
"""Try TCP connect. Returns (port, open, rtt_ms)."""
t0 = time.monotonic()
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout,
)
rtt = (time.monotonic() - t0) * 1000
writer.close()
await writer.wait_closed()
return port, True, rtt
except (OSError, asyncio.TimeoutError, TimeoutError):
return port, False, 0.0
def _parse_ports(spec: str) -> list[int]:
"""Parse a port spec like '22,80,443' or '1-1024' or 'common'."""
if spec.lower() == "common":
return list(_COMMON_PORTS)
ports: list[int] = []
for part in spec.split(","):
part = part.strip()
if "-" in part:
lo, _, hi = part.partition("-")
lo_i, hi_i = int(lo), int(hi)
if lo_i < 1 or hi_i > 65535 or lo_i > hi_i:
continue
ports.extend(range(lo_i, min(hi_i + 1, lo_i + _MAX_PORTS + 1)))
else:
p = int(part)
if 1 <= p <= 65535:
ports.append(p)
return ports[:_MAX_PORTS]
@command("portcheck", help="TCP port scan: !portcheck <host> [ports|common]")
async def cmd_portcheck(bot, message):
"""Async TCP connect scan against a host.
Usage:
!portcheck 10.0.0.1 (top 25 common ports)
!portcheck 10.0.0.1 22,80,443 (specific ports)
!portcheck 10.0.0.1 1-100 (range, max 20)
"""
parts = message.text.split(None, 3)
if len(parts) < 2:
await bot.reply(message, "Usage: !portcheck <host> [ports|common]")
return
host = parts[1]
if not _validate_host(host):
await bot.reply(message, f"Invalid host: {host}")
return
if _is_internal(host):
await bot.reply(message, f"Refused: {host} is an internal/reserved address")
return
port_spec = parts[2] if len(parts) > 2 else "common"
try:
ports = _parse_ports(port_spec)
except ValueError:
await bot.reply(message, f"Invalid port spec: {port_spec}")
return
if not ports:
await bot.reply(message, "No valid ports to scan")
return
tasks = [_check_port(host, p, _TIMEOUT) for p in ports]
results = await asyncio.gather(*tasks)
open_ports = [(p, rtt) for p, is_open, rtt in results if is_open]
if open_ports:
lines = []
for port, rtt in sorted(open_ports):
name = _PORT_NAMES.get(port, "")
label = f"{port}/{name}" if name else str(port)
lines.append(f"{label} ({rtt:.0f}ms)")
await bot.reply(message, f"{host} open: {', '.join(lines)}")
else:
await bot.reply(message, f"{host}: no open ports found ({len(ports)} scanned)")

117
plugins/rand.py Normal file
View File

@@ -0,0 +1,117 @@
"""Plugin: cryptographic random generators (pure stdlib)."""
from __future__ import annotations
import secrets
import string
import uuid
from derp.plugin import command
_MAX_LENGTH = 128
_DEFAULT_LENGTH = 16
_DEFAULT_HEX_LENGTH = 32
_CHARSETS = {
"alnum": string.ascii_letters + string.digits,
"alpha": string.ascii_letters,
"digits": string.digits,
"hex": string.hexdigits[:16],
"upper": string.ascii_uppercase + string.digits,
"lower": string.ascii_lowercase + string.digits,
"safe": string.ascii_letters + string.digits + "!@#$%^&*-_=+",
"all": string.ascii_letters + string.digits + string.punctuation,
}
@command("rand", help="Random gen: !rand <password|hex|uuid|bytes|int> [len]")
async def cmd_rand(bot, message):
"""Generate cryptographically random values.
Usage:
!rand password [len] [charset] -- random password (default 16)
!rand hex [len] -- random hex string (default 32)
!rand uuid -- random UUID4
!rand bytes [len] -- random bytes as hex (default 16)
!rand int [max] -- random integer 0..max (default 1000000)
!rand coin -- coin flip
!rand dice [NdM] -- dice roll (default 1d6)
"""
parts = message.text.split(None, 4)
if len(parts) < 2:
await bot.reply(message, "Usage: !rand <password|hex|uuid|bytes|int|coin|dice> [args]")
return
mode = parts[1].lower()
if mode == "password":
length = _DEFAULT_LENGTH
charset_name = "safe"
if len(parts) > 2:
try:
length = int(parts[2])
except ValueError:
charset_name = parts[2].lower()
if len(parts) > 3:
charset_name = parts[3].lower()
length = max(1, min(length, _MAX_LENGTH))
charset = _CHARSETS.get(charset_name, _CHARSETS["safe"])
pw = "".join(secrets.choice(charset) for _ in range(length))
await bot.reply(message, pw)
elif mode == "hex":
length = _DEFAULT_HEX_LENGTH
if len(parts) > 2:
try:
length = int(parts[2])
except ValueError:
pass
length = max(1, min(length, _MAX_LENGTH))
await bot.reply(message, secrets.token_hex(length // 2 + length % 2)[:length])
elif mode == "uuid":
await bot.reply(message, str(uuid.uuid4()))
elif mode == "bytes":
length = 16
if len(parts) > 2:
try:
length = int(parts[2])
except ValueError:
pass
length = max(1, min(length, _MAX_LENGTH // 2))
await bot.reply(message, secrets.token_bytes(length).hex())
elif mode == "int":
upper = 1000000
if len(parts) > 2:
try:
upper = int(parts[2])
except ValueError:
pass
upper = max(1, min(upper, 2**32))
await bot.reply(message, str(secrets.randbelow(upper)))
elif mode == "coin":
await bot.reply(message, secrets.choice(["heads", "tails"]))
elif mode == "dice":
spec = parts[2] if len(parts) > 2 else "1d6"
try:
num, _, sides = spec.lower().partition("d")
num_dice = int(num) if num else 1
num_sides = int(sides) if sides else 6
if num_dice < 1 or num_dice > 20 or num_sides < 2 or num_sides > 100:
raise ValueError
except ValueError:
await bot.reply(message, "Usage: !rand dice [NdM] (1-20 dice, 2-100 sides)")
return
rolls = [secrets.randbelow(num_sides) + 1 for _ in range(num_dice)]
total = sum(rolls)
if num_dice == 1:
await bot.reply(message, str(total))
else:
await bot.reply(message, f"{' + '.join(map(str, rolls))} = {total}")
else:
await bot.reply(message, "Modes: password, hex, uuid, bytes, int, coin, dice")

140
plugins/timer.py Normal file
View File

@@ -0,0 +1,140 @@
"""Plugin: countdown timer with async notifications (pure stdlib)."""
from __future__ import annotations
import asyncio
import re
from derp.plugin import command
# In-memory timer storage: {channel_or_nick: {label: task}}
_timers: dict[str, dict[str, asyncio.Task]] = {}
_MAX_TIMERS = 10
_MAX_DURATION = 86400 # 24h
_DURATION_RE = re.compile(
r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$",
)
def _parse_duration(spec: str) -> int | None:
"""Parse a duration like '5m', '1h30m', '90s', or raw seconds."""
# Try raw integer seconds
try:
secs = int(spec)
return secs if 0 < secs <= _MAX_DURATION else None
except ValueError:
pass
m = _DURATION_RE.match(spec.lower())
if not m or not any(m.groups()):
return None
hours = int(m.group(1) or 0)
mins = int(m.group(2) or 0)
secs = int(m.group(3) or 0)
total = hours * 3600 + mins * 60 + secs
return total if 0 < total <= _MAX_DURATION else None
def _format_duration(secs: int) -> str:
"""Format seconds into compact duration."""
parts = []
if secs >= 3600:
parts.append(f"{secs // 3600}h")
secs %= 3600
if secs >= 60:
parts.append(f"{secs // 60}m")
secs %= 60
if secs or not parts:
parts.append(f"{secs}s")
return "".join(parts)
async def _timer_task(bot, target: str, label: str, duration: int, nick: str) -> None:
"""Background task that waits and then notifies."""
try:
await asyncio.sleep(duration)
await bot.send(target, f"{nick}: timer '{label}' ({_format_duration(duration)}) finished")
except asyncio.CancelledError:
pass
finally:
# Clean up
if target in _timers:
_timers[target].pop(label, None)
if not _timers[target]:
del _timers[target]
@command("timer", help="Timer: !timer <duration> [label] | !timer list | !timer cancel <label>")
async def cmd_timer(bot, message):
"""Set countdown timers with async notification.
Usage:
!timer 5m -- 5 minute timer
!timer 1h30m deploy -- named timer
!timer 90 -- 90 seconds
!timer list -- show active timers
!timer cancel deploy -- cancel a timer
"""
parts = message.text.split(None, 3)
if len(parts) < 2:
await bot.reply(message, "Usage: !timer <duration> [label] | list | cancel <label>")
return
target = message.target if message.is_channel else message.nick
sub = parts[1].lower()
# List timers
if sub == "list":
active = _timers.get(target, {})
if not active:
await bot.reply(message, "No active timers")
return
items = []
for label, task in sorted(active.items()):
# We can't easily get remaining time from asyncio.Task,
# so just show the label and status
status = "running" if not task.done() else "done"
items.append(f"{label} ({status})")
await bot.reply(message, f"Timers: {', '.join(items)}")
return
# Cancel timer
if sub == "cancel":
if len(parts) < 3:
await bot.reply(message, "Usage: !timer cancel <label>")
return
label = parts[2]
active = _timers.get(target, {})
task = active.get(label)
if task and not task.done():
task.cancel()
await bot.reply(message, f"Cancelled timer: {label}")
else:
await bot.reply(message, f"No active timer: {label}")
return
# Set timer
duration = _parse_duration(parts[1])
if duration is None:
await bot.reply(message, "Invalid duration (use: 5m, 1h30m, 90s, max 24h)")
return
label = parts[2] if len(parts) > 2 else _format_duration(duration)
if target not in _timers:
_timers[target] = {}
if len(_timers[target]) >= _MAX_TIMERS:
await bot.reply(message, f"Too many timers (max {_MAX_TIMERS}), cancel some first")
return
if label in _timers[target] and not _timers[target][label].done():
await bot.reply(message, f"Timer '{label}' already running, cancel it first")
return
task = asyncio.create_task(
_timer_task(bot, target, label, duration, message.nick),
)
_timers[target][label] = task
await bot.reply(message, f"Timer set: '{label}' ({_format_duration(duration)})")

139
plugins/tlscheck.py Normal file
View File

@@ -0,0 +1,139 @@
"""Plugin: TLS certificate and cipher inspector (pure stdlib)."""
from __future__ import annotations
import asyncio
import hashlib
import socket
import ssl
from datetime import datetime, timezone
from derp.plugin import command
_TIMEOUT = 10
def _inspect(host: str, port: int) -> dict:
"""Blocking TLS connection to extract cert and cipher info."""
result: dict = {
"error": "",
"version": "",
"cipher": "",
"bits": 0,
"subject": "",
"issuer": "",
"san": [],
"not_before": "",
"not_after": "",
"days_left": 0,
"serial": "",
"fingerprint": "",
}
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with socket.create_connection((host, port), timeout=_TIMEOUT) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
result["version"] = ssock.version() or ""
cipher = ssock.cipher()
if cipher:
result["cipher"] = cipher[0]
result["bits"] = cipher[2]
cert_bin = ssock.getpeercert(binary_form=True)
cert = ssock.getpeercert()
if cert_bin:
result["fingerprint"] = hashlib.sha256(cert_bin).hexdigest()[:16]
if cert:
# Subject CN
for rdn in cert.get("subject", ()):
for attr, val in rdn:
if attr == "commonName":
result["subject"] = val
# Issuer CN / O
for rdn in cert.get("issuer", ()):
for attr, val in rdn:
if attr in ("commonName", "organizationName"):
if not result["issuer"]:
result["issuer"] = val
# SAN
san = cert.get("subjectAltName", ())
result["san"] = [v for t, v in san if t == "DNS"]
# Validity dates
nb = cert.get("notBefore", "")
na = cert.get("notAfter", "")
result["not_before"] = nb
result["not_after"] = na
if na:
try:
exp = datetime.strptime(na, "%b %d %H:%M:%S %Y %Z")
exp = exp.replace(tzinfo=timezone.utc)
delta = exp - datetime.now(timezone.utc)
result["days_left"] = delta.days
except ValueError:
pass
result["serial"] = cert.get("serialNumber", "")
except (OSError, ssl.SSLError) as exc:
result["error"] = str(exc)
return result
@command("tlscheck", help="TLS inspect: !tlscheck <host> [port]")
async def cmd_tlscheck(bot, message):
"""Inspect TLS version, cipher, and certificate details.
Usage:
!tlscheck example.com
!tlscheck 10.0.0.1 8443
"""
parts = message.text.split(None, 3)
if len(parts) < 2:
await bot.reply(message, "Usage: !tlscheck <host> [port]")
return
host = parts[1]
port = 443
if len(parts) > 2:
try:
port = int(parts[2])
if not 1 <= port <= 65535:
raise ValueError
except ValueError:
await bot.reply(message, f"Invalid port: {parts[2]}")
return
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, _inspect, host, port)
if result["error"]:
await bot.reply(message, f"{host}:{port} -> TLS error: {result['error']}")
return
info = [f"{result['version']} {result['cipher']} ({result['bits']}bit)"]
if result["subject"]:
info.append(f"CN={result['subject']}")
if result["issuer"]:
info.append(f"issuer={result['issuer']}")
if result["days_left"]:
label = "expires" if result["days_left"] > 0 else "EXPIRED"
info.append(f"{label} in {abs(result['days_left'])}d")
if result["san"]:
san_str = ", ".join(result["san"][:5])
if len(result["san"]) > 5:
san_str += f" (+{len(result['san']) - 5})"
info.append(f"SAN: {san_str}")
await bot.reply(message, f"{host}:{port} -> {' | '.join(info)}")

118
plugins/whois.py Normal file
View File

@@ -0,0 +1,118 @@
"""Plugin: WHOIS lookup (raw TCP, port 43, pure stdlib)."""
from __future__ import annotations
import asyncio
import ipaddress
from derp.plugin import command
# Referral servers for common TLDs
_SERVERS = {
"com": "whois.verisign-grs.com",
"net": "whois.verisign-grs.com",
"org": "whois.pir.org",
"info": "whois.afilias.net",
"io": "whois.nic.io",
"me": "whois.nic.me",
"co": "whois.nic.co",
"de": "whois.denic.de",
"uk": "whois.nic.uk",
"nl": "whois.domain-registry.nl",
"eu": "whois.eu",
"be": "whois.dns.be",
"fr": "whois.nic.fr",
"ru": "whois.tcinet.ru",
"au": "whois.auda.org.au",
"ca": "whois.cira.ca",
"us": "whois.nic.us",
}
_IP_SERVER = "whois.arin.net"
_FALLBACK = "whois.iana.org"
_TIMEOUT = 10.0
_MAX_RESPONSE = 4096
def _pick_server(target: str) -> tuple[str, str]:
"""Return (whois_server, query_string) for a target."""
try:
ipaddress.ip_address(target)
return _IP_SERVER, target
except ValueError:
pass
# Domain: pick server by TLD
parts = target.rstrip(".").split(".")
tld = parts[-1].lower() if parts else ""
server = _SERVERS.get(tld, _FALLBACK)
return server, target
async def _whois(server: str, query: str) -> str:
"""Send a WHOIS query and return the response text."""
reader, writer = await asyncio.wait_for(
asyncio.open_connection(server, 43), timeout=_TIMEOUT,
)
try:
writer.write(f"{query}\r\n".encode("utf-8"))
await writer.drain()
data = await asyncio.wait_for(reader.read(_MAX_RESPONSE), timeout=_TIMEOUT)
return data.decode("utf-8", errors="replace")
finally:
writer.close()
await writer.wait_closed()
def _extract_fields(raw: str) -> list[str]:
"""Pull key registration fields from raw WHOIS text."""
keys = {
"domain name", "registrar", "creation date", "registry expiry date",
"updated date", "name server", "status", "netname", "netrange",
"cidr", "orgname", "organization", "country",
}
results: list[str] = []
seen: set[str] = set()
for line in raw.splitlines():
line = line.strip()
if ":" not in line:
continue
key, _, val = line.partition(":")
key_lower = key.strip().lower()
val = val.strip()
if key_lower in keys and val and key_lower not in seen:
seen.add(key_lower)
results.append(f"{key.strip()}: {val}")
return results
@command("whois", help="WHOIS lookup: !whois <domain|ip>")
async def cmd_whois(bot, message):
"""Query WHOIS for a domain or IP address."""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !whois <domain|ip>")
return
target = parts[1].lower().strip()
server, query = _pick_server(target)
try:
raw = await _whois(server, query)
except (TimeoutError, asyncio.TimeoutError):
await bot.reply(message, f"{target}: timeout ({server})")
return
except OSError as exc:
await bot.reply(message, f"{target}: connection error: {exc}")
return
fields = _extract_fields(raw)
if fields:
await bot.reply(message, " | ".join(fields[:6]))
else:
# Show first non-empty line as fallback
for line in raw.splitlines():
line = line.strip()
if line and not line.startswith("%") and not line.startswith("#"):
await bot.reply(message, line[:400])
return
await bot.reply(message, f"{target}: no data from {server}")

View File

@@ -32,6 +32,13 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="enable debug logging",
)
p.add_argument(
"--cprofile",
metavar="PATH",
nargs="?",
const="derp.prof",
help="enable cProfile; dump stats to PATH [derp.prof]",
)
p.add_argument(
"-V", "--version",
action="version",
@@ -40,6 +47,14 @@ def build_parser() -> argparse.ArgumentParser:
return p
def _run(bot: Bot) -> None:
"""Run the bot event loop."""
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
logging.getLogger("derp").info("interrupted, shutting down")
def main(argv: list[str] | None = None) -> int:
"""Main entry point."""
parser = build_parser()
@@ -57,10 +72,14 @@ def main(argv: list[str] | None = None) -> int:
bot = Bot(config, registry)
bot.load_plugins()
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
log.info("interrupted, shutting down")
if args.cprofile:
import cProfile
log.info("cProfile enabled, output: %s", args.cprofile)
cProfile.runctx("_run(bot)", globals(), {"bot": bot, "_run": _run}, args.cprofile)
log.info("profile saved to %s", args.cprofile)
else:
_run(bot)
return 0