feat: add canary, tcping, archive, resolve plugins
canary: generate realistic fake credentials (token/aws/basic) for planting as canary tripwires. Per-channel state persistence. tcping: TCP connect latency probe through SOCKS5 proxy with min/avg/max reporting. Proxy-compatible alternative to traceroute. archive: save URLs to Wayback Machine via Save Page Now API, routed through SOCKS5 proxy. resolve: bulk DNS resolution (up to 10 hosts) via TCP DNS through SOCKS5 proxy with concurrent asyncio.gather. 83 new tests (1010 total), docs updated.
This commit is contained in:
105
plugins/archive.py
Normal file
105
plugins/archive.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Plugin: Wayback Machine Save Page Now (SOCKS5-proxied)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from derp.http import urlopen as _urlopen
|
||||
from derp.plugin import command
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SAVE_URL = "https://web.archive.org/save/"
|
||||
_TIMEOUT = 30
|
||||
_USER_AGENT = "derp/1.0"
|
||||
|
||||
|
||||
def _save_page(url: str) -> dict:
|
||||
"""Blocking POST to Save Page Now. Returns result dict."""
|
||||
target = f"{_SAVE_URL}{url}"
|
||||
req = urllib.request.Request(
|
||||
target,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
)
|
||||
|
||||
try:
|
||||
resp = _urlopen(req, timeout=_TIMEOUT)
|
||||
# The save endpoint returns a redirect to the archived page.
|
||||
# With urllib3 pooled requests, redirects are followed automatically.
|
||||
final_url = getattr(resp, "geturl", lambda: None)()
|
||||
headers = resp.headers if hasattr(resp, "headers") else {}
|
||||
|
||||
# Check for Content-Location or Link header with archived URL
|
||||
content_location = None
|
||||
if hasattr(headers, "get"):
|
||||
content_location = headers.get("Content-Location", "")
|
||||
link = headers.get("Link", "")
|
||||
else:
|
||||
content_location = ""
|
||||
link = ""
|
||||
|
||||
resp.read()
|
||||
|
||||
# Try Content-Location first (most reliable)
|
||||
if content_location and "/web/" in content_location:
|
||||
if content_location.startswith("/"):
|
||||
return {"url": f"https://web.archive.org{content_location}"}
|
||||
return {"url": content_location}
|
||||
|
||||
# Try final URL after redirects
|
||||
if final_url and "/web/" in final_url:
|
||||
return {"url": final_url}
|
||||
|
||||
# Try Link header
|
||||
if link and "/web/" in link:
|
||||
# Extract URL from Link header: <url>; rel="memento"
|
||||
for part in link.split(","):
|
||||
part = part.strip()
|
||||
if "/web/" in part and "<" in part:
|
||||
extracted = part.split("<", 1)[1].split(">", 1)[0]
|
||||
return {"url": extracted}
|
||||
|
||||
# If we got a 200 but no archive URL, report success without link
|
||||
return {"url": f"https://web.archive.org/web/*/{url}"}
|
||||
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 429:
|
||||
return {"error": "rate limited -- try again later"}
|
||||
if exc.code == 523:
|
||||
return {"error": "origin unreachable"}
|
||||
return {"error": f"HTTP {exc.code}"}
|
||||
except (TimeoutError, OSError) as exc:
|
||||
return {"error": f"timeout: {exc}"}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)[:100]}
|
||||
|
||||
|
||||
@command("archive", help="Save to Wayback Machine: !archive <url>")
|
||||
async def cmd_archive(bot, message):
|
||||
"""Save a URL to the Wayback Machine via Save Page Now.
|
||||
|
||||
Usage:
|
||||
!archive https://example.com/page
|
||||
"""
|
||||
parts = message.text.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !archive <url>")
|
||||
return
|
||||
|
||||
url = parts[1].strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
await bot.reply(message, "URL must start with http:// or https://")
|
||||
return
|
||||
|
||||
await bot.reply(message, f"Archiving {url}...")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, _save_page, url)
|
||||
|
||||
if "error" in result:
|
||||
await bot.reply(message, f"Archive failed: {result['error']}")
|
||||
else:
|
||||
await bot.reply(message, f"Archived: {result['url']}")
|
||||
197
plugins/canary.py
Normal file
197
plugins/canary.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Plugin: canary token generator -- plant realistic fake credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
_MAX_PER_CHANNEL = 50
|
||||
|
||||
|
||||
def _gen_token() -> str:
|
||||
"""40-char hex string (looks like API key / SHA1)."""
|
||||
return secrets.token_hex(20)
|
||||
|
||||
|
||||
def _gen_aws() -> dict[str, str]:
|
||||
"""AWS-style keypair: AKIA + 16 alnum access key, 40-char base64 secret."""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
access = "AKIA" + "".join(secrets.choice(chars) for _ in range(16))
|
||||
# 30 random bytes -> 40-char base64
|
||||
secret = secrets.token_urlsafe(30)
|
||||
return {"access_key": access, "secret_key": secret}
|
||||
|
||||
|
||||
def _gen_basic() -> dict[str, str]:
|
||||
"""Random user:pass pair."""
|
||||
alnum = string.ascii_lowercase + string.digits
|
||||
user = "svc" + "".join(secrets.choice(alnum) for _ in range(5))
|
||||
pw = secrets.token_urlsafe(16)
|
||||
return {"user": user, "pass": pw}
|
||||
|
||||
|
||||
_TYPES = {
|
||||
"token": "API token (40-char hex)",
|
||||
"aws": "AWS keypair (AKIA access + secret)",
|
||||
"basic": "Username:password pair",
|
||||
}
|
||||
|
||||
|
||||
def _load(bot, channel: str) -> dict:
|
||||
"""Load canary store for a channel."""
|
||||
raw = bot.state.get("canary", channel)
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save(bot, channel: str, store: dict) -> None:
|
||||
"""Persist canary store for a channel."""
|
||||
bot.state.set("canary", channel, json.dumps(store))
|
||||
|
||||
|
||||
def _format_token(entry: dict) -> str:
|
||||
"""Format a canary entry for display."""
|
||||
ttype = entry["type"]
|
||||
value = entry["value"]
|
||||
if ttype == "aws":
|
||||
return f"Access: {value['access_key']} Secret: {value['secret_key']}"
|
||||
if ttype == "basic":
|
||||
return f"{value['user']}:{value['pass']}"
|
||||
return value
|
||||
|
||||
|
||||
@command("canary", help="Canary tokens: !canary gen [type] <label> | list | info | del")
|
||||
async def cmd_canary(bot, message):
|
||||
"""Generate and manage canary tokens (fake credentials for detection).
|
||||
|
||||
Usage:
|
||||
!canary gen [type] <label> Generate token (admin)
|
||||
!canary list List canaries in channel
|
||||
!canary info <label> Show full token details
|
||||
!canary del <label> Delete a canary (admin)
|
||||
"""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !canary <gen|list|info|del> [args]")
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
channel = message.target if message.is_channel else None
|
||||
|
||||
# ---- gen -----------------------------------------------------------------
|
||||
if sub == "gen":
|
||||
if not bot._is_admin(message):
|
||||
await bot.reply(message, "Permission denied")
|
||||
return
|
||||
if not channel:
|
||||
await bot.reply(message, "Use this command in a channel")
|
||||
return
|
||||
|
||||
# Parse: !canary gen [type] <label>
|
||||
rest = parts[2:]
|
||||
if not rest:
|
||||
types = ", ".join(_TYPES)
|
||||
await bot.reply(message, f"Usage: !canary gen [type] <label> (types: {types})")
|
||||
return
|
||||
|
||||
# Check if first arg is a type
|
||||
ttype = "token"
|
||||
if rest[0].lower() in _TYPES:
|
||||
ttype = rest[0].lower()
|
||||
rest = rest[1:]
|
||||
|
||||
if not rest:
|
||||
await bot.reply(message, "Usage: !canary gen [type] <label>")
|
||||
return
|
||||
|
||||
label = rest[0].lower()
|
||||
if len(label) > 32 or not all(c.isalnum() or c in "-_" for c in label):
|
||||
await bot.reply(message, "Label: 1-32 chars, alphanumeric/hyphens/underscores")
|
||||
return
|
||||
|
||||
store = _load(bot, channel)
|
||||
if label in store:
|
||||
await bot.reply(message, f"Canary '{label}' already exists")
|
||||
return
|
||||
if len(store) >= _MAX_PER_CHANNEL:
|
||||
await bot.reply(message, f"Limit reached ({_MAX_PER_CHANNEL} per channel)")
|
||||
return
|
||||
|
||||
# Generate
|
||||
if ttype == "aws":
|
||||
value = _gen_aws()
|
||||
elif ttype == "basic":
|
||||
value = _gen_basic()
|
||||
else:
|
||||
value = _gen_token()
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
store[label] = {"type": ttype, "value": value, "created": now}
|
||||
_save(bot, channel, store)
|
||||
|
||||
display = _format_token(store[label])
|
||||
await bot.reply(message, f"Canary '{label}' ({ttype}): {display}")
|
||||
return
|
||||
|
||||
# ---- list ----------------------------------------------------------------
|
||||
if sub == "list":
|
||||
if not channel:
|
||||
await bot.reply(message, "Use this command in a channel")
|
||||
return
|
||||
store = _load(bot, channel)
|
||||
if not store:
|
||||
await bot.reply(message, "No canaries in this channel")
|
||||
return
|
||||
items = [f"{lbl} ({e['type']})" for lbl, e in sorted(store.items())]
|
||||
await bot.reply(message, f"Canaries: {', '.join(items)}")
|
||||
return
|
||||
|
||||
# ---- info ----------------------------------------------------------------
|
||||
if sub == "info":
|
||||
if not channel:
|
||||
await bot.reply(message, "Use this command in a channel")
|
||||
return
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !canary info <label>")
|
||||
return
|
||||
label = parts[2].lower()
|
||||
store = _load(bot, channel)
|
||||
entry = store.get(label)
|
||||
if not entry:
|
||||
await bot.reply(message, f"No canary '{label}'")
|
||||
return
|
||||
display = _format_token(entry)
|
||||
await bot.reply(message, f"{label} ({entry['type']}, {entry['created']}): {display}")
|
||||
return
|
||||
|
||||
# ---- del -----------------------------------------------------------------
|
||||
if sub == "del":
|
||||
if not bot._is_admin(message):
|
||||
await bot.reply(message, "Permission denied")
|
||||
return
|
||||
if not channel:
|
||||
await bot.reply(message, "Use this command in a channel")
|
||||
return
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !canary del <label>")
|
||||
return
|
||||
label = parts[2].lower()
|
||||
store = _load(bot, channel)
|
||||
if label not in store:
|
||||
await bot.reply(message, f"No canary '{label}'")
|
||||
return
|
||||
del store[label]
|
||||
_save(bot, channel, store)
|
||||
await bot.reply(message, f"Deleted canary '{label}'")
|
||||
return
|
||||
|
||||
# ---- unknown -------------------------------------------------------------
|
||||
await bot.reply(message, "Usage: !canary <gen|list|info|del> [args]")
|
||||
115
plugins/resolve.py
Normal file
115
plugins/resolve.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Plugin: bulk DNS resolution over TCP (SOCKS5-proxied)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import struct
|
||||
|
||||
from derp.dns import (
|
||||
QTYPES,
|
||||
RCODES,
|
||||
build_query,
|
||||
parse_response,
|
||||
reverse_name,
|
||||
)
|
||||
from derp.http import open_connection as _open_connection
|
||||
from derp.plugin import command
|
||||
|
||||
_DEFAULT_SERVER = "1.1.1.1"
|
||||
_TIMEOUT = 5.0
|
||||
_MAX_HOSTS = 10
|
||||
|
||||
|
||||
async def _query_tcp(name: str, qtype: int, server: str,
|
||||
timeout: float = _TIMEOUT) -> tuple[int, list[str]]:
|
||||
"""Send a DNS query over TCP and return (rcode, [values])."""
|
||||
reader, writer = await asyncio.wait_for(
|
||||
_open_connection(server, 53, timeout=timeout), timeout=timeout,
|
||||
)
|
||||
try:
|
||||
pkt = build_query(name, qtype)
|
||||
writer.write(struct.pack("!H", len(pkt)) + pkt)
|
||||
await writer.drain()
|
||||
length = struct.unpack("!H", await reader.readexactly(2))[0]
|
||||
data = await reader.readexactly(length)
|
||||
return parse_response(data)
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
async def _resolve_one(host: str, qtype_str: str,
|
||||
server: str) -> str:
|
||||
"""Resolve a single host, return formatted result line."""
|
||||
qtype = QTYPES.get(qtype_str)
|
||||
lookup = host
|
||||
|
||||
if qtype_str == "PTR":
|
||||
try:
|
||||
lookup = reverse_name(host)
|
||||
except ValueError:
|
||||
return f"{host} -> invalid IP for PTR"
|
||||
|
||||
try:
|
||||
rcode, results = await _query_tcp(lookup, qtype, server)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
return f"{host} -> timeout"
|
||||
except OSError as exc:
|
||||
return f"{host} -> error: {exc}"
|
||||
|
||||
if rcode != 0:
|
||||
err = RCODES.get(rcode, f"error {rcode}")
|
||||
return f"{host} -> {err}"
|
||||
if not results:
|
||||
return f"{host} -> no records"
|
||||
return f"{host} -> {', '.join(results)}"
|
||||
|
||||
|
||||
@command("resolve", help="Bulk DNS: !resolve <host> [host2 ...] [type]")
|
||||
async def cmd_resolve(bot, message):
|
||||
"""Bulk DNS resolution via TCP through SOCKS5 proxy.
|
||||
|
||||
Usage:
|
||||
!resolve example.com github.com (A records)
|
||||
!resolve example.com AAAA (specific type)
|
||||
!resolve 1.2.3.4 8.8.8.8 (auto PTR)
|
||||
"""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !resolve <host> [host2 ...] [type]")
|
||||
return
|
||||
|
||||
args = parts[1:]
|
||||
|
||||
# Check if last arg is a record type
|
||||
qtype_str = None
|
||||
if args[-1].upper() in QTYPES:
|
||||
qtype_str = args[-1].upper()
|
||||
args = args[:-1]
|
||||
|
||||
if not args:
|
||||
await bot.reply(message, "Usage: !resolve <host> [host2 ...] [type]")
|
||||
return
|
||||
|
||||
hosts = args[:_MAX_HOSTS]
|
||||
|
||||
# Auto-detect type per host if not specified
|
||||
async def _do(host: str) -> str:
|
||||
qt = qtype_str
|
||||
if qt is None:
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
qt = "PTR"
|
||||
except ValueError:
|
||||
qt = "A"
|
||||
return await _resolve_one(host, qt, _DEFAULT_SERVER)
|
||||
|
||||
results = await asyncio.gather(*[_do(h) for h in hosts])
|
||||
|
||||
lines = list(results)
|
||||
if len(args) > _MAX_HOSTS:
|
||||
lines.append(f"(showing first {_MAX_HOSTS} of {len(args)})")
|
||||
|
||||
for line in lines:
|
||||
await bot.reply(message, line)
|
||||
116
plugins/tcping.py
Normal file
116
plugins/tcping.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Plugin: TCP connect latency probe (SOCKS5-proxied)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import time
|
||||
|
||||
from derp.http import open_connection as _open_connection
|
||||
from derp.plugin import command
|
||||
|
||||
_TIMEOUT = 10.0
|
||||
_MAX_COUNT = 10
|
||||
_DEFAULT_COUNT = 3
|
||||
_DEFAULT_PORT = 443
|
||||
|
||||
|
||||
def _is_internal(host: str) -> bool:
|
||||
"""Check if host is 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 _probe(host: str, port: int, timeout: float) -> float | None:
|
||||
"""Single TCP connect probe. Returns RTT in ms or None on failure."""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
_open_connection(host, port, timeout=timeout), timeout=timeout,
|
||||
)
|
||||
rtt = (time.perf_counter() - t0) * 1000
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return rtt
|
||||
except (OSError, asyncio.TimeoutError, TimeoutError):
|
||||
return None
|
||||
|
||||
|
||||
@command("tcping", help="TCP latency: !tcping <host> [port] [count]")
|
||||
async def cmd_tcping(bot, message):
|
||||
"""Measure TCP connect latency to a host:port through SOCKS5 proxy.
|
||||
|
||||
Usage:
|
||||
!tcping example.com (port 443, 3 probes)
|
||||
!tcping example.com 22 (port 22, 3 probes)
|
||||
!tcping example.com 80 5 (port 80, 5 probes)
|
||||
"""
|
||||
parts = message.text.split()
|
||||
if len(parts) < 2:
|
||||
await bot.reply(message, "Usage: !tcping <host> [port] [count]")
|
||||
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 = _DEFAULT_PORT
|
||||
count = _DEFAULT_COUNT
|
||||
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
port = int(parts[2])
|
||||
if port < 1 or port > 65535:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await bot.reply(message, f"Invalid port: {parts[2]}")
|
||||
return
|
||||
|
||||
if len(parts) > 3:
|
||||
try:
|
||||
count = int(parts[3])
|
||||
count = max(1, min(count, _MAX_COUNT))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
results: list[float | None] = []
|
||||
for _ in range(count):
|
||||
rtt = await _probe(host, port, _TIMEOUT)
|
||||
results.append(rtt)
|
||||
|
||||
successes = [r for r in results if r is not None]
|
||||
|
||||
if not successes:
|
||||
await bot.reply(message, f"tcping {host}:{port} -- {count} probes, all timed out")
|
||||
return
|
||||
|
||||
probe_strs = []
|
||||
for i, r in enumerate(results, 1):
|
||||
probe_strs.append(f"{i}: {r:.0f}ms" if r is not None else f"{i}: timeout")
|
||||
|
||||
mn = min(successes)
|
||||
avg = sum(successes) / len(successes)
|
||||
mx = max(successes)
|
||||
|
||||
header = f"tcping {host}:{port} -- {count} probes"
|
||||
probes = " ".join(probe_strs)
|
||||
summary = f"min/avg/max: {mn:.0f}/{avg:.0f}/{mx:.0f} ms"
|
||||
await bot.reply(message, f"{header} {probes} {summary}")
|
||||
Reference in New Issue
Block a user