Files
derp/scripts/test_client.py
user 36b21e2463 feat: concurrent command dispatch and profiling test client
Replace sequential await in command/event dispatch with
asyncio.create_task() so slow commands (whois, httpcheck, tlscheck)
no longer block the read loop. Add _spawn() for task lifecycle
tracking. Enable cProfile in docker-compose for profiling. Add
scripts/test_client.py for end-to-end plugin testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:09:53 +01:00

212 lines
5.9 KiB
Python

"""Gentle IRC test client for profiling derp bot commands."""
from __future__ import annotations
import asyncio
import ssl
import sys
import time
HOST = "mymx.me"
PORT = 6697
PASSWORD = "irc$1234="
NICK = "tester"
CHANNEL = "#derp"
DELAY = 2.5 # seconds between commands -- be nice
# Commands to test, grouped by plugin
TESTS: list[tuple[str, str]] = [
# -- core --
("core", "!ping"),
("core", "!help"),
("core", "!help ping"),
("core", "!help dns"),
("core", "!version"),
("core", "!uptime"),
("core", "!plugins"),
# -- example --
("example", "!echo profiling test run"),
# -- dns --
("dns", "!dns example.com"),
("dns", "!dns example.com MX"),
("dns", "!dns 1.1.1.1"),
# -- encode --
("encode", "!encode b64 hello world"),
("encode", "!decode b64 aGVsbG8gd29ybGQ="),
("encode", "!encode hex derp"),
("encode", "!encode rot13 hello"),
# -- hash --
("hash", "!hash hello"),
("hash", "!hash sha512 hello"),
("hash", "!hashid 5d41402abc4b2a76b9719d911017c592"),
# -- defang --
("defang", "!defang https://evil.com/path?q=1"),
("defang", "!refang hxxps[://]evil[.]com/path"),
# -- revshell --
("revshell", "!revshell list"),
("revshell", "!revshell bash 10.0.0.1 4444"),
# -- cidr --
("cidr", "!cidr 192.168.1.0/24"),
("cidr", "!cidr contains 10.0.0.0/8 10.1.2.3"),
# -- whois --
("whois", "!whois example.com"),
# -- portcheck --
("portcheck", "!portcheck example.com 80,443"),
# -- httpcheck --
("httpcheck", "!httpcheck https://example.com"),
# -- tlscheck --
("tlscheck", "!tlscheck example.com"),
# -- blacklist (use a known-safe public DNS) --
("blacklist", "!blacklist 8.8.8.8"),
# -- rand --
("rand", "!rand password"),
("rand", "!rand hex 16"),
("rand", "!rand uuid"),
("rand", "!rand int 100"),
("rand", "!rand coin"),
("rand", "!rand dice 2d6"),
# -- timer --
("timer", "!timer 5s profile-test"),
("timer", "!timer list"),
# -- crtsh (external API, single domain) --
("crtsh", "!cert example.com"),
# -- shorthand --
("shorthand", "!pi"),
("shorthand", "!ver"),
]
async def main() -> None:
"""Connect to IRC and run through all bot commands."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
print(f"[*] connecting to {HOST}:{PORT} (TLS)...")
reader, writer = await asyncio.open_connection(HOST, PORT, ssl=ctx)
async def send(line: str) -> None:
writer.write(f"{line}\r\n".encode())
await writer.drain()
print(f">>> {line}")
async def read_until(match: str, timeout: float = 15.0) -> list[str]:
lines: list[str] = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
data = await asyncio.wait_for(reader.readline(), timeout=2.0)
except asyncio.TimeoutError:
continue
if not data:
break
line = data.decode("utf-8", errors="replace").strip()
print(f"<<< {line}")
lines.append(line)
if line.startswith("PING"):
pong = line.replace("PING", "PONG", 1)
await send(pong)
if match in line:
break
return lines
# -- Register --
await send(f"PASS {PASSWORD}")
await send(f"NICK {NICK}")
await send(f"USER {NICK} 0 * :derp test client")
await read_until("376") # End of MOTD
await asyncio.sleep(1)
await send(f"JOIN {CHANNEL}")
await read_until("366") # End of NAMES
await asyncio.sleep(1)
# -- Run tests --
total = len(TESTS)
passed = 0
results: list[tuple[str, str, bool]] = []
for i, (plugin, cmd) in enumerate(TESTS, 1):
print(f"\n[{i}/{total}] ({plugin}) {cmd}")
await send(f"PRIVMSG {CHANNEL} :{cmd}")
# Wait for bot response (look for PRIVMSG from derp)
got_reply = False
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
try:
data = await asyncio.wait_for(reader.readline(), timeout=3.0)
except asyncio.TimeoutError:
continue
if not data:
break
line = data.decode("utf-8", errors="replace").strip()
print(f"<<< {line}")
if line.startswith("PING"):
pong = line.replace("PING", "PONG", 1)
await send(pong)
if "PRIVMSG" in line and ":derp!" in line.lower():
got_reply = True
break
status = "OK" if got_reply else "NO REPLY"
results.append((plugin, cmd, got_reply))
if got_reply:
passed += 1
print(f" -> {status}")
await asyncio.sleep(DELAY)
# -- Wait for timer callback --
print("\n[*] waiting for timer notification (5s)...")
await asyncio.sleep(6)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
data = await asyncio.wait_for(reader.readline(), timeout=2.0)
except asyncio.TimeoutError:
break
if not data:
break
line = data.decode("utf-8", errors="replace").strip()
print(f"<<< {line}")
# -- Summary --
print(f"\n{'=' * 50}")
print(f"Results: {passed}/{total} commands got replies")
print(f"{'=' * 50}")
for plugin, cmd, ok in results:
mark = "+" if ok else "-"
print(f" [{mark}] {plugin:12s} {cmd}")
# -- Disconnect --
await send(f"PART {CHANNEL} :test complete")
await send("QUIT :profiling done")
writer.close()
try:
await writer.wait_closed()
except ssl.SSLError:
pass # server may close before SSL shutdown completes
sys.exit(0 if passed == total else 1)
if __name__ == "__main__":
asyncio.run(main())