Files
derp/plugins/resolve.py
user e3bb793574 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.
2026-02-20 19:38:10 +01:00

116 lines
3.2 KiB
Python

"""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)