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:
4
TASKS.md
4
TASKS.md
@@ -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
16
TODO.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
132
plugins/blacklist.py
Normal 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
109
plugins/httpcheck.py
Normal 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
128
plugins/portcheck.py
Normal 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
117
plugins/rand.py
Normal 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
140
plugins/timer.py
Normal 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
139
plugins/tlscheck.py
Normal 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
118
plugins/whois.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user