fix: route blacklist and subdomain DNS through Tor resolver

Both plugins duplicated wire-format helpers and queried the system
resolver on port 53. Switch to shared derp.dns helpers and point
queries at the local Tor DNS resolver (127.0.0.1:9053) so lookups
go through Tor like all other outbound traffic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 16:16:57 +01:00
parent 7520bba192
commit d5866a9867
3 changed files with 30 additions and 136 deletions

View File

@@ -1,13 +1,12 @@
"""Plugin: DNSBL/RBL IP reputation check (pure stdlib).""" """Plugin: DNSBL/RBL IP reputation check via Tor DNS resolver."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress import ipaddress
import os
import socket import socket
import struct
from derp.dns import TOR_DNS_ADDR, TOR_DNS_PORT, build_query, parse_response
from derp.plugin import command from derp.plugin import command
_DNSBLS = [ _DNSBLS = [
@@ -25,54 +24,16 @@ _DNSBLS = [
_TIMEOUT = 5.0 _TIMEOUT = 5.0
def _get_resolver() -> str: def _query_dnsbl(name: str) -> bool:
"""Read first IPv4 nameserver from /etc/resolv.conf.""" """Blocking DNS A lookup via Tor resolver, returns True if listed."""
try: query = build_query(name, 1)
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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(_TIMEOUT) sock.settimeout(_TIMEOUT)
try: try:
sock.sendto(query, (server, 53)) sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT))
data = sock.recv(512) data = sock.recv(512)
return _check_response(data) rcode, results = parse_response(data)
return rcode == 0 and len(results) > 0
except (socket.timeout, OSError): except (socket.timeout, OSError):
return False return False
finally: finally:
@@ -84,12 +45,12 @@ def _reversed_ip(addr: str) -> str:
return ".".join(reversed(addr.split("."))) return ".".join(reversed(addr.split(".")))
async def _check_one(ip_rev: str, zone: str, label: str, async def _check_one(ip_rev: str, zone: str,
server: str) -> tuple[str, bool]: label: str) -> tuple[str, bool]:
"""Check one DNSBL asynchronously.""" """Check one DNSBL asynchronously."""
name = f"{ip_rev}.{zone}" name = f"{ip_rev}.{zone}"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
listed = await loop.run_in_executor(None, _query_dnsbl, name, server) listed = await loop.run_in_executor(None, _query_dnsbl, name)
return label, listed return label, listed
@@ -117,9 +78,8 @@ async def cmd_blacklist(bot, message):
return return
ip_rev = _reversed_ip(str(ip)) ip_rev = _reversed_ip(str(ip))
server = _get_resolver()
tasks = [_check_one(ip_rev, zone, label, server) for zone, label in _DNSBLS] tasks = [_check_one(ip_rev, zone, label) for zone, label in _DNSBLS]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
listed = [label for label, hit in results if hit] listed = [label for label, hit in results if hit]

View File

@@ -3,15 +3,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress
import json import json
import logging import logging
import os
import re import re
import socket import socket
import struct
import urllib.request import urllib.request
from derp.dns import TOR_DNS_ADDR, TOR_DNS_PORT, build_query, parse_response
from derp.http import urlopen as _urlopen from derp.http import urlopen as _urlopen
from derp.plugin import command from derp.plugin import command
@@ -43,84 +41,19 @@ _WORDLIST = [
_DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$") _DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$")
def _get_resolver() -> str: def _resolve_a(name: str) -> list[str]:
"""Read first IPv4 nameserver from /etc/resolv.conf.""" """Blocking DNS A lookup via Tor resolver. Returns list of IPs."""
try: query = build_query(name, 1)
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 _parse_a_response(data: bytes) -> list[str]:
"""Extract A record IPs from a DNS response."""
if len(data) < 12:
return []
_, flags, _, ancount = struct.unpack_from("!HHHH", data, 0)
rcode = flags & 0x0F
if rcode != 0 or ancount == 0:
return []
offset = 12
# Skip question section
while offset < len(data) and data[offset] != 0:
if (data[offset] & 0xC0) == 0xC0:
offset += 2
break
offset += data[offset] + 1
else:
offset += 1
offset += 4 # QTYPE + QCLASS
results = []
for _ in range(ancount):
if offset + 12 > len(data):
break
# Skip name (may be pointer)
if (data[offset] & 0xC0) == 0xC0:
offset += 2
else:
while offset < len(data) and data[offset] != 0:
offset += data[offset] + 1
offset += 1
rtype, _, _, rdlength = struct.unpack_from("!HHIH", data, offset)
offset += 10
if rtype == 1 and rdlength == 4:
results.append(socket.inet_ntoa(data[offset:offset + 4]))
offset += rdlength
return results
def _resolve_a(name: str, server: str) -> list[str]:
"""Blocking DNS A lookup. Returns list of IPs."""
query = _build_a_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(_DNS_TIMEOUT) sock.settimeout(_DNS_TIMEOUT)
try: try:
sock.sendto(query, (server, 53)) sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT))
data = sock.recv(4096) data = sock.recv(4096)
return _parse_a_response(data) rcode, results = parse_response(data)
if rcode != 0:
return []
# Filter to only A record IPs (dotted-quad format)
return [r for r in results if "." in r and ":" not in r]
except (socket.timeout, OSError): except (socket.timeout, OSError):
return [] return []
finally: finally:
@@ -148,12 +81,11 @@ def _fetch_crtsh(domain: str) -> set[str]:
return subs return subs
async def _brute_one(prefix: str, domain: str, async def _brute_one(prefix: str, domain: str) -> tuple[str, list[str]]:
server: str) -> tuple[str, list[str]]:
"""Resolve one subdomain. Returns (fqdn, [ips]).""" """Resolve one subdomain. Returns (fqdn, [ips])."""
fqdn = f"{prefix}.{domain}" fqdn = f"{prefix}.{domain}"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
ips = await loop.run_in_executor(None, _resolve_a, fqdn, server) ips = await loop.run_in_executor(None, _resolve_a, fqdn)
return fqdn, ips return fqdn, ips
@@ -189,8 +121,7 @@ async def cmd_subdomain(bot, message):
timeout=35.0, timeout=35.0,
) )
# Resolve the CT-discovered subdomains # Resolve the CT-discovered subdomains
server = _get_resolver() tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain)
tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain, server)
for sub in ct_subs] for sub in ct_subs]
if tasks: if tasks:
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
@@ -205,8 +136,7 @@ async def cmd_subdomain(bot, message):
# Phase 2: DNS brute force (optional) # Phase 2: DNS brute force (optional)
if brute: if brute:
server = _get_resolver() tasks = [_brute_one(w, domain) for w in _WORDLIST
tasks = [_brute_one(w, domain, server) for w in _WORDLIST
if f"{w}.{domain}" not in found] if f"{w}.{domain}" not in found]
if tasks: if tasks:
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)

View File

@@ -17,6 +17,10 @@ RCODES: dict[int, str] = {
4: "NOTIMP", 5: "REFUSED", 4: "NOTIMP", 5: "REFUSED",
} }
# Tor DNS resolver (DNSPort on the local Tor relay)
TOR_DNS_ADDR = "127.0.0.1"
TOR_DNS_PORT = 9053
def get_resolver() -> str: def get_resolver() -> str:
"""Read first IPv4 nameserver from /etc/resolv.conf.""" """Read first IPv4 nameserver from /etc/resolv.conf."""