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:
146
src/derp/dns.py
Normal file
146
src/derp/dns.py
Normal 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"
|
||||
Reference in New Issue
Block a user