feat(dns): add BIND-flavored DNS honeypot service
Python asyncio DNS server on UDP+TCP/53 masquerading as BIND 9.x. Emits four event_type values: query, fingerprint_probe (version.bind / hostname.bind / id.server CHAOS), zone_transfer (AXFR/IXFR, always REFUSED), amp_probe (qtype=ANY or EDNS udp_size>1232), and tunneling_suspect (long high-entropy labels or rapid TXT burst). Zone persona is generated per-decky from instance_seed (domain name, SOA serial, NS, A, MX, TXT SPF); overridable via config_schema. Three zone modes: auth (default), recursive, open (sinkhole).
This commit is contained in:
551
decnet/templates/dns/server.py
Normal file
551
decnet/templates/dns/server.py
Normal file
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DNS server (UDP+TCP/53) — BIND 9.x persona.
|
||||
|
||||
event_type values emitted:
|
||||
query — standard resolution attempt
|
||||
fingerprint_probe — version.bind / hostname.bind / id.server CHAOS queries
|
||||
zone_transfer — AXFR or IXFR (always REFUSED)
|
||||
amp_probe — qtype=ANY or EDNS requestor udp_size > 1232
|
||||
tunneling_suspect — long high-entropy labels or rapid TXT burst from same src
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
from syslog_bridge import forward_syslog, syslog_line, write_syslog_file
|
||||
import instance_seed as seed
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ns1")
|
||||
SERVICE_NAME = "dns"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
ZONE_MODE = os.environ.get("DNS_ZONE_MODE", "auth")
|
||||
BIND_VERSION = os.environ.get("DNS_BIND_VERSION", "9.11.4-P2-RedHat-9.11.4-26.P2.el7_9.10")
|
||||
_NSID_RAW = os.environ.get("DNS_NSID", "")
|
||||
_EXTRA_RAW = os.environ.get("DNS_EXTRA_RECORDS", "")
|
||||
|
||||
# ── Zone generation ───────────────────────────────────────────────────────────
|
||||
|
||||
_CORP_NAMES = ["nexus", "apex", "vantage", "summit", "meridian", "vector",
|
||||
"axiom", "helios", "stratos", "cortex", "vertex", "praxis"]
|
||||
_CORP_SUFFIXES = ["corp", "systems", "tech", "group", "labs", "net"]
|
||||
_TLDS = ["local", "internal", "corp", "lan"]
|
||||
|
||||
|
||||
def _generate_domain() -> str:
|
||||
custom = os.environ.get("DNS_DOMAIN", "").strip()
|
||||
if custom:
|
||||
return custom.rstrip(".") + "."
|
||||
name = seed.pick(_CORP_NAMES)
|
||||
suffix = seed.pick(_CORP_SUFFIXES)
|
||||
tld = seed.pick(_TLDS)
|
||||
return f"{name}-{suffix}.{tld}."
|
||||
|
||||
|
||||
DOMAIN = _generate_domain()
|
||||
DOMAIN_BARE = DOMAIN.rstrip(".")
|
||||
NSID = _NSID_RAW if _NSID_RAW else seed.instance_uuid("nsid")[:16]
|
||||
|
||||
_SOA_SERIAL = int(seed.instance_hex(4, "soa-serial"), 16) % 99 + 2020010101
|
||||
NS1 = f"ns1.{DOMAIN_BARE}."
|
||||
NS2 = f"ns2.{DOMAIN_BARE}."
|
||||
|
||||
|
||||
def _fake_ip(label: str = "") -> str:
|
||||
h = int(seed.instance_hex(3, f"ip:{label}"), 16)
|
||||
return f"10.{(h >> 16) & 0xFF}.{(h >> 8) & 0xFF}.{h & 0xFF}"
|
||||
|
||||
|
||||
ZONE_IP = _fake_ip("zone")
|
||||
_NS2_IP = _fake_ip("ns2")
|
||||
|
||||
# Parse extra_records: one per line, "<name> <TYPE> <value>"
|
||||
_EXTRA_RECORDS: list[tuple[str, str, str]] = []
|
||||
for _line in _EXTRA_RAW.splitlines():
|
||||
_parts = _line.strip().split(None, 2)
|
||||
if len(_parts) == 3:
|
||||
_EXTRA_RECORDS.append((_parts[0], _parts[1].upper(), _parts[2]))
|
||||
|
||||
# ── DNS wire constants ────────────────────────────────────────────────────────
|
||||
|
||||
TYPE_A = 1
|
||||
TYPE_NS = 2
|
||||
TYPE_SOA = 6
|
||||
TYPE_MX = 15
|
||||
TYPE_TXT = 16
|
||||
TYPE_AAAA = 28
|
||||
TYPE_OPT = 41
|
||||
TYPE_IXFR = 251
|
||||
TYPE_AXFR = 252
|
||||
TYPE_ANY = 255
|
||||
|
||||
CLASS_IN = 1
|
||||
CLASS_CH = 3
|
||||
CLASS_ANY = 255
|
||||
|
||||
RCODE_NOERROR = 0
|
||||
RCODE_FORMERR = 1
|
||||
RCODE_SERVFAIL = 2
|
||||
RCODE_NXDOMAIN = 3
|
||||
RCODE_NOTIMP = 4
|
||||
RCODE_REFUSED = 5
|
||||
|
||||
_TYPE_NAMES = {
|
||||
TYPE_A: "A", TYPE_NS: "NS", TYPE_SOA: "SOA", TYPE_MX: "MX",
|
||||
TYPE_TXT: "TXT", TYPE_AAAA: "AAAA", TYPE_IXFR: "IXFR",
|
||||
TYPE_AXFR: "AXFR", TYPE_OPT: "OPT", TYPE_ANY: "ANY",
|
||||
}
|
||||
_CLASS_NAMES = {CLASS_IN: "IN", CLASS_CH: "CH", CLASS_ANY: "ANY"}
|
||||
|
||||
# ── Wire codec ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encode_name(fqdn: str) -> bytes:
|
||||
"""Encode a DNS name to wire format (no compression)."""
|
||||
if not fqdn or fqdn == ".":
|
||||
return b"\x00"
|
||||
out = b""
|
||||
for label in fqdn.rstrip(".").split("."):
|
||||
enc = label.encode("ascii", errors="replace")
|
||||
out += bytes([len(enc)]) + enc
|
||||
return out + b"\x00"
|
||||
|
||||
|
||||
def _decode_name(data: bytes, offset: int) -> tuple[str, int]:
|
||||
"""Decode a DNS name supporting RFC 1035 pointer compression."""
|
||||
labels: list[str] = []
|
||||
next_offset = -1
|
||||
jumps = 0
|
||||
while True:
|
||||
if offset >= len(data):
|
||||
raise ValueError("truncated name")
|
||||
length = data[offset]
|
||||
if length == 0:
|
||||
if next_offset < 0:
|
||||
next_offset = offset + 1
|
||||
break
|
||||
if (length & 0xC0) == 0xC0:
|
||||
if offset + 1 >= len(data):
|
||||
raise ValueError("truncated pointer")
|
||||
if next_offset < 0:
|
||||
next_offset = offset + 2
|
||||
jumps += 1
|
||||
if jumps > 10:
|
||||
raise ValueError("compression loop")
|
||||
offset = ((length & 0x3F) << 8) | data[offset + 1]
|
||||
else:
|
||||
offset += 1
|
||||
if offset + length > len(data):
|
||||
raise ValueError("truncated label")
|
||||
labels.append(
|
||||
data[offset:offset + length].decode("ascii", errors="replace").lower()
|
||||
)
|
||||
offset += length
|
||||
name = ".".join(labels) + "." if labels else "."
|
||||
return name, next_offset
|
||||
|
||||
|
||||
def _rr(name: str, rtype: int, rclass: int, ttl: int, rdata: bytes) -> bytes:
|
||||
name_enc = _encode_name(name)
|
||||
return name_enc + struct.pack(">HHIH", rtype, rclass, ttl, len(rdata)) + rdata
|
||||
|
||||
|
||||
def _rdata_A(ip: str) -> bytes:
|
||||
return bytes(int(x) for x in ip.split("."))
|
||||
|
||||
|
||||
def _rdata_NS(ns: str) -> bytes:
|
||||
return _encode_name(ns)
|
||||
|
||||
|
||||
def _rdata_TXT(text: str) -> bytes:
|
||||
enc = text.encode("ascii", errors="replace")[:255]
|
||||
return bytes([len(enc)]) + enc
|
||||
|
||||
|
||||
def _rdata_MX(priority: int, exchange: str) -> bytes:
|
||||
return struct.pack(">H", priority) + _encode_name(exchange)
|
||||
|
||||
|
||||
def _rdata_SOA(
|
||||
mname: str, rname: str,
|
||||
serial: int, refresh: int, retry: int, expire: int, minimum: int,
|
||||
) -> bytes:
|
||||
return (
|
||||
_encode_name(mname)
|
||||
+ _encode_name(rname)
|
||||
+ struct.pack(">IIIII", serial, refresh, retry, expire, minimum)
|
||||
)
|
||||
|
||||
|
||||
def _build_header(
|
||||
qid: int, flags: int,
|
||||
qdcount: int, ancount: int, nscount: int, arcount: int,
|
||||
) -> bytes:
|
||||
return struct.pack(">HHHHHH", qid, flags, qdcount, ancount, nscount, arcount)
|
||||
|
||||
|
||||
def _flags_response(
|
||||
rd: bool = False, ra: bool = False, aa: bool = False, rcode: int = 0,
|
||||
) -> int:
|
||||
f = 0x8000 # QR=1
|
||||
if aa:
|
||||
f |= 0x0400
|
||||
if rd:
|
||||
f |= 0x0100
|
||||
if ra:
|
||||
f |= 0x0080
|
||||
f |= (rcode & 0x0F)
|
||||
return f
|
||||
|
||||
|
||||
def _parse_question(data: bytes, offset: int) -> tuple[str, int, int, int]:
|
||||
"""Return (qname, qtype, qclass, next_offset)."""
|
||||
qname, offset = _decode_name(data, offset)
|
||||
if offset + 4 > len(data):
|
||||
raise ValueError("truncated question")
|
||||
qtype, qclass = struct.unpack_from(">HH", data, offset)
|
||||
return qname, qtype, qclass, offset + 4
|
||||
|
||||
|
||||
def _parse_edns_size(data: bytes, qdcount: int, ancount: int, nscount: int, arcount: int) -> int | None:
|
||||
"""Walk to the additional section; return requestor UDP size if OPT found."""
|
||||
if arcount == 0:
|
||||
return None
|
||||
offset = 12
|
||||
try:
|
||||
for _ in range(qdcount):
|
||||
_, offset = _decode_name(data, offset)
|
||||
offset += 4
|
||||
for _ in range(ancount + nscount):
|
||||
_, offset = _decode_name(data, offset)
|
||||
if offset + 10 > len(data):
|
||||
return None
|
||||
rdlen = struct.unpack_from(">H", data, offset + 8)[0]
|
||||
offset += 10 + rdlen
|
||||
for _ in range(arcount):
|
||||
if offset >= len(data):
|
||||
return None
|
||||
if data[offset] == 0:
|
||||
# Root label — candidate OPT record
|
||||
if offset + 11 > len(data):
|
||||
return None
|
||||
rtype = struct.unpack_from(">H", data, offset + 1)[0]
|
||||
if rtype == TYPE_OPT:
|
||||
udp_size = struct.unpack_from(">H", data, offset + 3)[0]
|
||||
return udp_size
|
||||
_, offset = _decode_name(data, offset)
|
||||
if offset + 10 > len(data):
|
||||
return None
|
||||
rdlen = struct.unpack_from(">H", data, offset + 8)[0]
|
||||
offset += 10 + rdlen
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
# ── Tunneling heuristic ───────────────────────────────────────────────────────
|
||||
|
||||
_SHANNON_THRESHOLD = 4.0
|
||||
_LABEL_LEN_THRESHOLD = 30
|
||||
_TXT_BURST_WINDOW = 10.0 # seconds
|
||||
_TXT_BURST_COUNT = 5
|
||||
_MAX_TRACKED_SRCS = 1000
|
||||
|
||||
# src_ip -> deque of recent TXT query timestamps (monotonic)
|
||||
_txt_times: collections.OrderedDict[str, collections.deque] = collections.OrderedDict()
|
||||
|
||||
|
||||
def _shannon_entropy(s: str) -> float:
|
||||
if not s:
|
||||
return 0.0
|
||||
freq: dict[str, int] = {}
|
||||
for c in s:
|
||||
freq[c] = freq.get(c, 0) + 1
|
||||
n = len(s)
|
||||
return -sum((v / n) * math.log2(v / n) for v in freq.values())
|
||||
|
||||
|
||||
def _is_tunneling(qname: str, qtype: int, src: str) -> bool:
|
||||
for label in qname.rstrip(".").split("."):
|
||||
if len(label) >= _LABEL_LEN_THRESHOLD and _shannon_entropy(label) > _SHANNON_THRESHOLD:
|
||||
return True
|
||||
if qtype == TYPE_TXT:
|
||||
now = time.monotonic()
|
||||
if src not in _txt_times:
|
||||
if len(_txt_times) >= _MAX_TRACKED_SRCS:
|
||||
_txt_times.popitem(last=False)
|
||||
_txt_times[src] = collections.deque()
|
||||
q = _txt_times[src]
|
||||
q.append(now)
|
||||
while q and now - q[0] > _TXT_BURST_WINDOW:
|
||||
q.popleft()
|
||||
if len(q) >= _TXT_BURST_COUNT:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Response builders ─────────────────────────────────────────────────────────
|
||||
|
||||
def _refused_response(qid: int, rd: bool, qname: str, qtype: int, qclass: int) -> bytes:
|
||||
flags = _flags_response(rd=rd, rcode=RCODE_REFUSED)
|
||||
q = _encode_name(qname) + struct.pack(">HH", qtype, qclass)
|
||||
return _build_header(qid, flags, 1, 0, 0, 0) + q
|
||||
|
||||
|
||||
def _soa_rr(ttl: int = 300) -> bytes:
|
||||
rdata = _rdata_SOA(
|
||||
NS1, f"hostmaster.{DOMAIN_BARE}.",
|
||||
_SOA_SERIAL, 3600, 900, 604800, 300,
|
||||
)
|
||||
return _rr(DOMAIN, TYPE_SOA, CLASS_IN, ttl, rdata)
|
||||
|
||||
|
||||
def _nxdomain_response(qid: int, rd: bool, qname: str, qtype: int, qclass: int) -> bytes:
|
||||
flags = _flags_response(rd=rd, aa=True, rcode=RCODE_NXDOMAIN)
|
||||
q = _encode_name(qname) + struct.pack(">HH", qtype, qclass)
|
||||
auth = _soa_rr(300)
|
||||
return _build_header(qid, flags, 1, 0, 1, 0) + q + auth
|
||||
|
||||
|
||||
def _chaos_txt_response(qid: int, rd: bool, qname: str, text: str) -> bytes:
|
||||
flags = _flags_response(rd=rd, aa=True, rcode=RCODE_NOERROR)
|
||||
q = _encode_name(qname) + struct.pack(">HH", TYPE_TXT, CLASS_CH)
|
||||
answer = _rr(qname, TYPE_TXT, CLASS_CH, 0, _rdata_TXT(text))
|
||||
return _build_header(qid, flags, 1, 1, 0, 0) + q + answer
|
||||
|
||||
|
||||
def _auth_response(qid: int, rd: bool, qname: str, qtype: int) -> bytes:
|
||||
"""Authoritative IN response for the generated zone."""
|
||||
qname_bare = qname.rstrip(".")
|
||||
in_zone = (
|
||||
qname_bare == DOMAIN_BARE
|
||||
or qname_bare.endswith("." + DOMAIN_BARE)
|
||||
)
|
||||
|
||||
# Out-of-zone handling
|
||||
if not in_zone:
|
||||
if ZONE_MODE == "open":
|
||||
# Sinkhole A: deterministic 127.0.0.x
|
||||
h = int(hashlib.sha256(qname.encode()).hexdigest()[:2], 16) or 1
|
||||
ip = f"127.0.0.{h}"
|
||||
flags = _flags_response(rd=rd, aa=False, rcode=RCODE_NOERROR)
|
||||
q = _encode_name(qname) + struct.pack(">HH", qtype, CLASS_IN)
|
||||
ans = _rr(qname, TYPE_A, CLASS_IN, 30, _rdata_A(ip))
|
||||
return _build_header(qid, flags, 1, 1, 0, 0) + q + ans
|
||||
if ZONE_MODE == "recursive":
|
||||
flags = _flags_response(rd=rd, aa=False, ra=True, rcode=RCODE_NXDOMAIN)
|
||||
q = _encode_name(qname) + struct.pack(">HH", qtype, CLASS_IN)
|
||||
return _build_header(qid, flags, 1, 0, 0, 0) + q
|
||||
return _refused_response(qid, rd, qname, qtype, CLASS_IN)
|
||||
|
||||
flags = _flags_response(rd=rd, aa=True, rcode=RCODE_NOERROR)
|
||||
q = _encode_name(qname) + struct.pack(">HH", qtype, CLASS_IN)
|
||||
answers: list[bytes] = []
|
||||
authority: list[bytes] = []
|
||||
|
||||
# Built-in zone records
|
||||
_well_known = {
|
||||
DOMAIN_BARE,
|
||||
f"www.{DOMAIN_BARE}",
|
||||
f"mail.{DOMAIN_BARE}",
|
||||
f"ns1.{DOMAIN_BARE}",
|
||||
f"ns2.{DOMAIN_BARE}",
|
||||
}
|
||||
|
||||
if qtype in (TYPE_A, TYPE_ANY):
|
||||
ip_map = {
|
||||
DOMAIN_BARE: ZONE_IP,
|
||||
f"www.{DOMAIN_BARE}": ZONE_IP,
|
||||
f"mail.{DOMAIN_BARE}": _fake_ip("mail"),
|
||||
f"ns1.{DOMAIN_BARE}": ZONE_IP,
|
||||
f"ns2.{DOMAIN_BARE}": _NS2_IP,
|
||||
}
|
||||
if qname_bare in ip_map:
|
||||
answers.append(_rr(qname, TYPE_A, CLASS_IN, 300, _rdata_A(ip_map[qname_bare])))
|
||||
|
||||
if qtype in (TYPE_NS, TYPE_ANY) and qname_bare == DOMAIN_BARE:
|
||||
answers.append(_rr(DOMAIN, TYPE_NS, CLASS_IN, 3600, _rdata_NS(NS1)))
|
||||
answers.append(_rr(DOMAIN, TYPE_NS, CLASS_IN, 3600, _rdata_NS(NS2)))
|
||||
|
||||
if qtype in (TYPE_SOA, TYPE_ANY) and qname_bare == DOMAIN_BARE:
|
||||
answers.append(_soa_rr())
|
||||
|
||||
if qtype in (TYPE_MX, TYPE_ANY) and qname_bare == DOMAIN_BARE:
|
||||
answers.append(_rr(DOMAIN, TYPE_MX, CLASS_IN, 3600, _rdata_MX(10, f"mail.{DOMAIN_BARE}.")))
|
||||
|
||||
if qtype in (TYPE_TXT, TYPE_ANY) and qname_bare == DOMAIN_BARE:
|
||||
answers.append(_rr(DOMAIN, TYPE_TXT, CLASS_IN, 3600, _rdata_TXT("v=spf1 a mx ~all")))
|
||||
|
||||
# User-supplied extra records
|
||||
for ername, ertype, erval in _EXTRA_RECORDS:
|
||||
er_fqdn = ername if ername.endswith(".") else f"{ername}.{DOMAIN_BARE}."
|
||||
er_bare = er_fqdn.rstrip(".")
|
||||
if qname_bare != er_bare:
|
||||
continue
|
||||
if ertype == "A" and qtype in (TYPE_A, TYPE_ANY):
|
||||
answers.append(_rr(er_fqdn, TYPE_A, CLASS_IN, 300, _rdata_A(erval)))
|
||||
elif ertype == "TXT" and qtype in (TYPE_TXT, TYPE_ANY):
|
||||
answers.append(_rr(er_fqdn, TYPE_TXT, CLASS_IN, 300, _rdata_TXT(erval)))
|
||||
elif ertype == "CNAME" and qtype in (TYPE_A, TYPE_ANY):
|
||||
answers.append(_rr(er_fqdn, 5, CLASS_IN, 300, _encode_name(erval)))
|
||||
|
||||
if not answers:
|
||||
if qname_bare not in _well_known:
|
||||
return _nxdomain_response(qid, rd, qname, qtype, CLASS_IN)
|
||||
# Name exists but no records of this type — NOERROR + SOA in authority
|
||||
authority.append(_soa_rr())
|
||||
|
||||
answer_bytes = b"".join(answers)
|
||||
auth_bytes = b"".join(authority)
|
||||
return (
|
||||
_build_header(qid, flags, 1, len(answers), len(authority), 0)
|
||||
+ q + answer_bytes + auth_bytes
|
||||
)
|
||||
|
||||
# ── Request dispatcher ────────────────────────────────────────────────────────
|
||||
|
||||
def _handle(data: bytes, src_ip: str, src_port: int, transport: str) -> bytes | None:
|
||||
"""Parse one DNS request and return the response wire bytes, emitting events."""
|
||||
if len(data) < 12:
|
||||
return None
|
||||
qid, flags_in, qdcount, ancount, nscount, arcount = struct.unpack_from(">HHHHHH", data, 0)
|
||||
if qdcount == 0:
|
||||
return None
|
||||
rd = bool(flags_in & 0x0100)
|
||||
|
||||
try:
|
||||
qname, qtype, qclass, _ = _parse_question(data, 12)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
edns_size = _parse_edns_size(data, qdcount, ancount, nscount, arcount)
|
||||
|
||||
qtype_name = _TYPE_NAMES.get(qtype, str(qtype))
|
||||
qclass_name = _CLASS_NAMES.get(qclass, str(qclass))
|
||||
|
||||
# ── Zone transfer ──────────────────────────────────────────────────────
|
||||
if qtype in (TYPE_AXFR, TYPE_IXFR):
|
||||
_log(
|
||||
"zone_transfer",
|
||||
src=src_ip, src_port=src_port, transport=transport,
|
||||
qname=qname.rstrip("."), qtype=qtype_name, qclass=qclass_name,
|
||||
zone=DOMAIN,
|
||||
)
|
||||
return _refused_response(qid, rd, qname, qtype, qclass)
|
||||
|
||||
# ── CHAOS fingerprinting ───────────────────────────────────────────────
|
||||
if qclass == CLASS_CH and qtype == TYPE_TXT:
|
||||
probe_map = {
|
||||
"version.bind.": BIND_VERSION,
|
||||
"hostname.bind.": NODE_NAME,
|
||||
"id.server.": NSID,
|
||||
}
|
||||
answer_text = probe_map.get(qname, "")
|
||||
_log(
|
||||
"fingerprint_probe",
|
||||
src=src_ip, src_port=src_port, transport=transport,
|
||||
probe=qname.rstrip("."), response=answer_text,
|
||||
)
|
||||
if answer_text:
|
||||
return _chaos_txt_response(qid, rd, qname, answer_text)
|
||||
return _refused_response(qid, rd, qname, qtype, qclass)
|
||||
|
||||
# ── Classify amp / tunneling ───────────────────────────────────────────
|
||||
is_amp = qtype == TYPE_ANY or (edns_size is not None and edns_size > 1232)
|
||||
is_tunnel = _is_tunneling(qname, qtype, src_ip)
|
||||
|
||||
response = _auth_response(qid, rd, qname, qtype)
|
||||
|
||||
# Emit events — tunneling and amp each get their own event; plain queries
|
||||
# only get logged when neither flag is set.
|
||||
base: dict[str, Any] = dict(
|
||||
src=src_ip, src_port=src_port, transport=transport,
|
||||
qname=qname.rstrip("."), qtype=qtype_name, qclass=qclass_name,
|
||||
edns_size=edns_size or 0, recursion_desired=rd,
|
||||
)
|
||||
if is_tunnel:
|
||||
_log("tunneling_suspect", **base)
|
||||
if is_amp:
|
||||
_log("amp_probe", **base)
|
||||
if not is_tunnel and not is_amp:
|
||||
_log("query", **base)
|
||||
|
||||
return response
|
||||
|
||||
# ── UDP transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _DNSUDPProtocol(asyncio.DatagramProtocol):
|
||||
_transport: asyncio.DatagramTransport | None = None
|
||||
|
||||
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||
self._transport = cast(asyncio.DatagramTransport, transport)
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||
try:
|
||||
response = _handle(data, addr[0], addr[1], "udp")
|
||||
if response and self._transport:
|
||||
self._transport.sendto(response, addr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
pass
|
||||
|
||||
# ── TCP transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _tcp_session(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
"""One DNS-over-TCP session; RFC 1035 §4.2.2 length-prefixed framing."""
|
||||
peername = writer.get_extra_info("peername") or ("0.0.0.0", 0)
|
||||
src_ip, src_port = peername[0], peername[1]
|
||||
try:
|
||||
while True:
|
||||
length_bytes = await asyncio.wait_for(reader.readexactly(2), timeout=10.0)
|
||||
msg_len = struct.unpack(">H", length_bytes)[0]
|
||||
if msg_len == 0:
|
||||
break
|
||||
data = await asyncio.wait_for(reader.readexactly(msg_len), timeout=10.0)
|
||||
response = _handle(data, src_ip, src_port, "tcp")
|
||||
if response:
|
||||
writer.write(struct.pack(">H", len(response)) + response)
|
||||
await writer.drain()
|
||||
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def main() -> None:
|
||||
_log("startup", msg=f"DNS server: zone={DOMAIN} mode={ZONE_MODE} version={BIND_VERSION}")
|
||||
loop = asyncio.get_running_loop()
|
||||
udp_transport, _ = await loop.create_datagram_endpoint(
|
||||
_DNSUDPProtocol, local_addr=("0.0.0.0", 53) # nosec B104
|
||||
)
|
||||
tcp_server = await asyncio.start_server(
|
||||
_tcp_session, "0.0.0.0", 53 # nosec B104
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(float("inf"))
|
||||
finally:
|
||||
udp_transport.close()
|
||||
tcp_server.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user