feat: add TCP DNS plugin with SOCKS5 proxy support

Extract shared DNS wire-format helpers into src/derp/dns.py so both
the UDP plugin (dns.py) and the new TCP plugin (tdns.py) share the
same encode/decode/build/parse logic.

The !tdns command routes queries through the SOCKS5 proxy via
derp.http.open_connection, using TCP framing (2-byte length prefix).
Default server: 1.1.1.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 16:09:35 +01:00
parent 1bdba0ea06
commit 26063a0e8f
8 changed files with 542 additions and 153 deletions

146
src/derp/dns.py Normal file
View File

@@ -0,0 +1,146 @@
"""Shared DNS wire-format helpers (encode, decode, build, parse)."""
from __future__ import annotations
import ipaddress
import os
import socket
import struct
QTYPES: dict[str, int] = {
"A": 1, "NS": 2, "CNAME": 5, "SOA": 6,
"PTR": 12, "MX": 15, "TXT": 16, "AAAA": 28,
}
QTYPE_NAMES: dict[int, str] = {v: k for k, v in QTYPES.items()}
RCODES: dict[int, str] = {
0: "", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN",
4: "NOTIMP", 5: "REFUSED",
}
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 encode_name(name: str) -> bytes:
"""Encode a domain name into DNS wire format."""
out = b""
for label in name.rstrip(".").split("."):
out += bytes([len(label)]) + label.encode("ascii")
return out + b"\x00"
def decode_name(data: bytes, offset: int) -> tuple[str, int]:
"""Decode a DNS name with pointer compression."""
labels: list[str] = []
jumped = False
ret_offset = offset
jumps = 0
while offset < len(data):
length = data[offset]
if length == 0:
if not jumped:
ret_offset = offset + 1
break
if (length & 0xC0) == 0xC0:
if not jumped:
ret_offset = offset + 2
ptr = struct.unpack_from("!H", data, offset)[0] & 0x3FFF
offset = ptr
jumped = True
jumps += 1
if jumps > 20:
break
continue
offset += 1
labels.append(data[offset:offset + length].decode("ascii", errors="replace"))
offset += length
if not jumped:
ret_offset = offset
return ".".join(labels), ret_offset
def build_query(name: str, qtype: int) -> bytes:
"""Build a DNS query packet."""
tid = os.urandom(2)
flags = struct.pack("!H", 0x0100)
counts = struct.pack("!HHHH", 1, 0, 0, 0)
return tid + flags + counts + encode_name(name) + struct.pack("!HH", qtype, 1)
def parse_rdata(rtype: int, data: bytes, offset: int, rdlength: int) -> str:
"""Parse an RR's rdata into a human-readable string."""
rdata = data[offset:offset + rdlength]
if rtype == 1 and rdlength == 4:
return socket.inet_ntoa(rdata)
if rtype == 28 and rdlength == 16:
return socket.inet_ntop(socket.AF_INET6, rdata)
if rtype in (2, 5, 12): # NS, CNAME, PTR
name, _ = decode_name(data, offset)
return name
if rtype == 15: # MX
pref = struct.unpack_from("!H", rdata, 0)[0]
mx, _ = decode_name(data, offset + 2)
return f"{pref} {mx}"
if rtype == 16: # TXT
parts: list[str] = []
pos = 0
while pos < rdlength:
tlen = rdata[pos]
pos += 1
parts.append(rdata[pos:pos + tlen].decode("utf-8", errors="replace"))
pos += tlen
return "".join(parts)
if rtype == 6: # SOA
mname, off = decode_name(data, offset)
rname, off = decode_name(data, off)
serial = struct.unpack_from("!I", data, off)[0]
return f"{mname} {rname} {serial}"
return rdata.hex()
def parse_response(data: bytes) -> tuple[int, list[str]]:
"""Parse a DNS response, returning (rcode, [values])."""
if len(data) < 12:
return 2, []
_, flags, qdcount, ancount = struct.unpack_from("!HHHH", data, 0)
rcode = flags & 0x0F
offset = 12
for _ in range(qdcount):
_, offset = decode_name(data, offset)
offset += 4
results: list[str] = []
for _ in range(ancount):
if offset + 10 > len(data):
break
_, offset = decode_name(data, offset)
rtype, _, _, rdlength = struct.unpack_from("!HHIH", data, offset)
offset += 10
if offset + rdlength > len(data):
break
results.append(parse_rdata(rtype, data, offset, rdlength))
offset += rdlength
return rcode, results
def reverse_name(addr: str) -> str:
"""Convert an IP address to its reverse DNS name."""
ip = ipaddress.ip_address(addr)
if isinstance(ip, ipaddress.IPv4Address):
return ".".join(reversed(addr.split("."))) + ".in-addr.arpa"
expanded = ip.exploded.replace(":", "")
return ".".join(reversed(expanded)) + ".ip6.arpa"