From 77a466e61578a26b4b904e53ec524e1ebabfb09d Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 21 May 2026 19:07:49 -0400 Subject: [PATCH] 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). --- decnet/services/dns.py | 83 ++++ decnet/templates/dns/Dockerfile | 26 ++ decnet/templates/dns/entrypoint.sh | 3 + decnet/templates/dns/instance_seed.py | 120 ++++++ decnet/templates/dns/server.py | 551 ++++++++++++++++++++++++++ decnet/templates/dns/syslog_bridge.py | 401 +++++++++++++++++++ tests/service_testing/test_dns.py | 349 ++++++++++++++++ 7 files changed, 1533 insertions(+) create mode 100644 decnet/services/dns.py create mode 100644 decnet/templates/dns/Dockerfile create mode 100644 decnet/templates/dns/entrypoint.sh create mode 100644 decnet/templates/dns/instance_seed.py create mode 100644 decnet/templates/dns/server.py create mode 100644 decnet/templates/dns/syslog_bridge.py create mode 100644 tests/service_testing/test_dns.py diff --git a/decnet/services/dns.py b/decnet/services/dns.py new file mode 100644 index 00000000..209bc7c9 --- /dev/null +++ b/decnet/services/dns.py @@ -0,0 +1,83 @@ +from pathlib import Path +from decnet.services.base import BaseService, ServiceConfigField + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "dns" + +_DEFAULT_VERSION = "9.11.4-P2-RedHat-9.11.4-26.P2.el7_9.10" + + +class DNSService(BaseService): + name = "dns" + ports = [53] + default_image = "build" + + config_schema = [ + ServiceConfigField( + key="zone_mode", + label="Zone mode", + type="enum", + enum=["auth", "recursive", "open"], + default="auth", + help="auth: authoritative only; recursive: RA flag set, NXDOMAIN for out-of-zone; open: responds to everything (amp bait)", + ), + ServiceConfigField( + key="domain", + label="Domain", + type="string", + default="", + placeholder="corp.local", + help="Authoritative zone name. Leave empty to generate a plausible domain from the decky name.", + ), + ServiceConfigField( + key="bind_version", + label="BIND version banner", + type="string", + default=_DEFAULT_VERSION, + help="Returned for version.bind CHAOS TXT queries.", + ), + ServiceConfigField( + key="nsid", + label="NSID", + type="string", + default="", + help="EDNS NSID string. Leave empty to derive from decky identity.", + ), + ServiceConfigField( + key="extra_records", + label="Extra records", + type="textarea", + default="", + placeholder="www A 10.0.0.5\nmail TXT v=spf1 ~all", + help="Additional zone records, one per line: ", + ), + ] + + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + cfg = service_cfg or {} + env: dict[str, str] = { + "NODE_NAME": decky_name, + "DNS_ZONE_MODE": str(cfg.get("zone_mode", "auth")), + "DNS_DOMAIN": str(cfg.get("domain", "")), + "DNS_BIND_VERSION": str(cfg.get("bind_version", _DEFAULT_VERSION)), + "DNS_NSID": str(cfg.get("nsid", "")), + "DNS_EXTRA_RECORDS": str(cfg.get("extra_records", "")), + } + if log_target: + env["LOG_TARGET"] = log_target + return { + "build": {"context": str(TEMPLATES_DIR)}, + "container_name": f"{decky_name}-dns", + "restart": "unless-stopped", + "environment": env, + } + + def dockerfile_context(self) -> Path | None: + return TEMPLATES_DIR + + def udp_ports(self, cfg: dict | None = None) -> list[int]: + return [53] diff --git a/decnet/templates/dns/Dockerfile b/decnet/templates/dns/Dockerfile new file mode 100644 index 00000000..49028392 --- /dev/null +++ b/decnet/templates/dns/Dockerfile @@ -0,0 +1,26 @@ +ARG BASE_IMAGE=debian:bookworm-slim@sha256:f9c6a2fd2ddbc23e336b6257a5245e31f996953ef06cd13a59fa0a1df2d5c252 +FROM ${BASE_IMAGE} + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY syslog_bridge.py /opt/syslog_bridge.py +COPY instance_seed.py /opt/instance_seed.py +COPY server.py /opt/server.py +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 53/udp +EXPOSE 53/tcp +RUN useradd -r -s /bin/false -d /opt logrelay \ + && apt-get update && apt-get install -y --no-install-recommends libcap2-bin \ + && rm -rf /var/lib/apt/lists/* \ + && (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true) + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD kill -0 1 || exit 1 + +USER logrelay +WORKDIR /opt +ENTRYPOINT ["/entrypoint.sh"] diff --git a/decnet/templates/dns/entrypoint.sh b/decnet/templates/dns/entrypoint.sh new file mode 100644 index 00000000..c830b733 --- /dev/null +++ b/decnet/templates/dns/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -e +exec python3 /opt/server.py diff --git a/decnet/templates/dns/instance_seed.py b/decnet/templates/dns/instance_seed.py new file mode 100644 index 00000000..61e1fecc --- /dev/null +++ b/decnet/templates/dns/instance_seed.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Per-instance stealth seeding for honeypot service templates. + +The whole decoy fleet looks identical to a scanner unless each decky +diverges on the boring details: cluster UUIDs, auth salts, uptime, minor +version strings, etc. This module derives a stable per-instance seed +from NODE_NAME (+ optional INSTANCE_ID) and exposes helpers that return +deterministic-per-decky-but-different-across-the-fleet values. + +Connection-time jitter is intentionally NOT seeded — two hits to the same +decky should not replay the same latency curve. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import random +import time +import uuid +from typing import Sequence, TypeVar + +T = TypeVar("T") + +_HOSTNAME = ( + os.environ.get("NODE_NAME") + or os.environ.get("HOSTNAME") + or "decky" +) +_INSTANCE_ID = os.environ.get("INSTANCE_ID", "") +_SEED_MATERIAL = f"{_HOSTNAME}:{_INSTANCE_ID}".encode() +_SEED_INT = int.from_bytes(hashlib.sha256(_SEED_MATERIAL).digest()[:8], "big") + +#: Deterministic RNG seeded per decky — use for *persistent* choices +#: (versions, UUIDs, stored credentials). Never use for timing. +rng = random.Random(_SEED_INT) + +#: Process boot time — real uptime elapsed since container start. +_PROCESS_START = time.time() + +#: Deterministic per-instance fake "has been up for this long at boot" +#: offset, so every decky pretends to have a different history. +_BOOT_OFFSET = rng.randint(3600, 45 * 86400) + + +def hostname() -> str: + return _HOSTNAME + + +def uptime_seconds() -> int: + """Monotonically increasing, unique per instance.""" + return int(_BOOT_OFFSET + (time.time() - _PROCESS_START)) + + +def boot_epoch() -> int: + """Fake wall-clock boot time for this instance (seconds since epoch).""" + return int(time.time() - uptime_seconds()) + + +def instance_uuid(namespace: str = "") -> str: + """Deterministic UUID4-looking value for this instance+namespace.""" + ns = uuid.UUID("00000000-0000-0000-0000-000000000000") + return str(uuid.uuid5(ns, f"{_HOSTNAME}:{namespace}")) + + +def instance_hex(nbytes: int, namespace: str = "") -> str: + """Deterministic hex token of given byte length.""" + material = f"{_HOSTNAME}:{namespace}".encode() + digest = hashlib.sha256(material).digest() + while len(digest) < nbytes: + digest += hashlib.sha256(digest).digest() + return digest[:nbytes].hex() + + +def pick(choices: Sequence[T]) -> T: + """Deterministic choice from a sequence.""" + return rng.choice(list(choices)) + + +def pick_weighted(choices: Sequence[tuple[T, float]]) -> T: + """Deterministic weighted choice. Input: [(item, weight), ...].""" + total = sum(w for _, w in choices) + r = rng.uniform(0, total) + acc = 0.0 + for item, w in choices: + acc += w + if r <= acc: + return item + return choices[-1][0] + + +def random_bytes(n: int, namespace: str = "") -> bytes: + """Deterministic per-instance byte string of length n.""" + out = bytearray() + i = 0 + while len(out) < n: + out.extend( + hashlib.sha256(f"{_HOSTNAME}:{namespace}:{i}".encode()).digest() + ) + i += 1 + return bytes(out[:n]) + + +def fresh_bytes(n: int) -> bytes: + """Non-deterministic random bytes — for per-connection nonces/salts.""" + return os.urandom(n) + + +async def jitter(min_ms: int = 5, max_ms: int = 120) -> None: + """Async response-time jitter. Uses unseeded RNG so timing varies + across connections to the same decky — seeded jitter would leak + predictability.""" + await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0) + + +def jitter_sync(min_ms: int = 5, max_ms: int = 120) -> None: + """Blocking jitter for non-asyncio servers.""" + time.sleep(random.uniform(min_ms, max_ms) / 1000.0) diff --git a/decnet/templates/dns/server.py b/decnet/templates/dns/server.py new file mode 100644 index 00000000..060ef6c4 --- /dev/null +++ b/decnet/templates/dns/server.py @@ -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, " " +_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()) diff --git a/decnet/templates/dns/syslog_bridge.py b/decnet/templates/dns/syslog_bridge.py new file mode 100644 index 00000000..de597390 --- /dev/null +++ b/decnet/templates/dns/syslog_bridge.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper used by service containers. + +Services call syslog_line() to format an RFC 5424 message, then +write_syslog_file() to emit it to stdout — the container runtime +captures it, and the host-side collector streams it into the log file. + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16). SD element ID uses PEN 55555. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib as _hashlib +import json as _json +import os as _os +import re +import socket as _socket +import threading as _threading +from datetime import datetime, timezone +from typing import Any, Optional + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "relay@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +def encode_secret(secret: str) -> dict[str, str]: + """Standardized credential-secret encoding for the universal SD-block shape. + + Returns ``{'secret_printable': ..., 'secret_b64': ...}`` ready to spread + into a :func:`syslog_line` / ``_log`` call:: + + _log("auth_attempt", principal=user, **encode_secret(password)) + + ``secret_printable`` mirrors auth-helper.c's sd_escape: bytes outside + ``[0x20, 0x7f)`` collapse to ``'?'`` so the field is always parser-safe + RFC 5424 ASCII. ``secret_b64`` preserves the *original* utf-8 bytes — + NUL/0xff/control/non-utf8 sequences all survive losslessly, useful as + a fingerprinting signal even when the printable form sanitizes them. + + The decnet web ingester's native-shape branch keys off ``secret_b64`` + being present, so any service emitter calling this helper lands its + cred attempt directly in the :class:`Credential` table. + """ + raw = secret.encode("utf-8", errors="replace") + printable = "".join(chr(b) if 0x20 <= b < 0x7f else "?" for b in raw) + return { + "secret_printable": printable, + "secret_b64": base64.b64encode(raw).decode("ascii"), + } + + +_DIGEST_PARAM_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"|(\w+)\s*=\s*([^,\s]+)') + + +def classify_authorization(header_value: Optional[str]) -> Optional[dict[str, Any]]: + """Parse an HTTP Authorization header value into Credential SD fields. + + Returns a dict with the universal cred shape ready to spread into a + ``_log(...)`` call:: + + auth = request.headers.get("Authorization") + cred = classify_authorization(auth) + if cred: + _log("auth_attempt", **cred) + + Recognised schemes: + * Basic — base64(user:pw); decoded → ``principal=user`` + + ``secret_kind="plaintext"`` + ``encode_secret(pw)``. + * Bearer / Token — opaque token; ``principal=None`` + + ``secret_kind="http_bearer"`` + ``encode_secret(token)``. + * Digest — ``principal=username`` from header + + ``secret_kind="http_digest_md5"`` + ``encode_secret(response)``. + + Returns ``None`` for anything unrecognized (AWS4-HMAC-SHA256, NTLM, + Negotiate, …) — callers can still log the raw header value in the + ambient SD-block; we just don't know how to extract a hashable + secret from it. + """ + if not header_value or not isinstance(header_value, str): + return None + parts = header_value.strip().split(None, 1) + if len(parts) < 2: + return None + scheme, rest = parts[0].lower(), parts[1].strip() + + if scheme == "basic": + try: + decoded = base64.b64decode(rest, validate=True).decode("utf-8", errors="replace") + except (ValueError, binascii.Error): + return None + if ":" not in decoded: + return None + user, _, pw = decoded.partition(":") + return { + "principal": user, + "secret_kind": "plaintext", + **encode_secret(pw), + } + + if scheme in ("bearer", "token"): + return { + "principal": None, + "secret_kind": "http_bearer", + **encode_secret(rest), + } + + if scheme == "digest": + params: dict[str, str] = {} + for m in _DIGEST_PARAM_RE.finditer(rest): + k = m.group(1) or m.group(3) + v = m.group(2) if m.group(2) is not None else m.group(4) + if k: + params[k.lower()] = v + response = params.get("response") + if not response: + return None + return { + "principal": params.get("username"), + "secret_kind": "http_digest_md5", + **encode_secret(response), + } + + return None + + +_FORM_PRINCIPAL_KEYS = ( + "username", "user", "email", "login", "userid", "account", + "log", # wp-login.php + "user_login", # WordPress alt + "uname", # phpMyAdmin + "pma_username", +) +_FORM_SECRET_KEYS = ( + "password", "pass", "pwd", "passwd", "passwort", "mot_de_passe", + "user_password", # WordPress alt + "pma_password", # phpMyAdmin +) + + +def extract_form_credentials( + body: Optional[str], + content_type: Optional[str], +) -> Optional[dict[str, Any]]: + """Parse an `application/x-www-form-urlencoded` body for credentials. + + Returns the universal cred SD shape ready to spread into a + ``_log(...)`` call when both a principal-shaped key and a secret- + shaped key are present in the body. Otherwise returns ``None``. + + Field-name detection is case-insensitive and covers the most common + login-form variants (WordPress wp-login.php, phpMyAdmin, Joomla, + etc.). Add more entries to ``_FORM_PRINCIPAL_KEYS`` / + ``_FORM_SECRET_KEYS`` as new templates surface them. + """ + if not body or not isinstance(content_type, str): + return None + if not content_type.lower().startswith("application/x-www-form-urlencoded"): + return None + + fields: dict[str, str] = {} + for pair in body.split("&"): + if "=" not in pair: + continue + k, _, v = pair.partition("=") + try: + from urllib.parse import unquote_plus + key = unquote_plus(k).lower() + val = unquote_plus(v) + except Exception: + continue + fields.setdefault(key, val) + + principal: Optional[str] = None + for k in _FORM_PRINCIPAL_KEYS: + if k in fields: + principal = fields[k] + break + secret: Optional[str] = None + for k in _FORM_SECRET_KEYS: + if k in fields: + secret = fields[k] + break + if secret is None: + return None + return { + "principal": principal, + "secret_kind": "plaintext", + **encode_secret(secret), + } + + +def write_syslog_file(line: str) -> None: + """Emit a syslog line to stdout for container log capture.""" + print(line, flush=True) + + +def forward_syslog(line: str, log_target: str) -> None: + """No-op stub. TCP forwarding is handled by rsyslog, not by service containers.""" + pass + + +# ─── JA4H (local copy — containers can't import from decnet.sniffer) ───────── + + +def _sha256_12(s: str) -> str: + return _hashlib.sha256(s.encode()).hexdigest()[:12] + + +def _compute_ja4h( + method: str, + proto: str, + headers_ordered: list, + cookie: str = "", + accept_lang: str = "", +) -> str: + """Compute JA4H per the FoxIO public spec. + + headers_ordered is a list of [name, value] pairs (or bare name strings). + """ + method_tag = (method[:2].upper() if method else "UN") + ver_map = { + "HTTP/1.0": "10", "HTTP/1.1": "11", "HTTP/2.0": "20", "HTTP/3.0": "30", + "H1": "11", "H2": "20", "H3": "30", + "h1": "11", "h2": "20", "h3": "30", + } + ver_tag = ver_map.get(proto.upper(), "00") + names = [ + (h[0].lower() if isinstance(h, (list, tuple)) else h.lower()) + for h in headers_ordered + ] + has_cookie = "c" if any(n == "cookie" for n in names) else "n" + has_referer = "r" if any(n == "referer" for n in names) else "n" + lang_tag = (accept_lang[:4].ljust(4, "0") if accept_lang else "0000") + filtered = [n for n in names if n not in ("cookie", "referer")] + count_tag = f"{min(len(filtered), 99):02d}" + header_hash = _sha256_12(",".join(filtered)) + if cookie: + pairs = sorted(p.strip() for p in cookie.split(";") if "=" in p.strip()) + cookie_hash = _sha256_12(";".join(pairs)) + else: + cookie_hash = "000000000000" + return f"{method_tag}{ver_tag}{has_cookie}{has_referer}{lang_tag}_{count_tag}_{header_hash}_{cookie_hash}" + + +# ─── Caddy fingerprint socket reader ───────────────────────────────────────── + +_FP_BUF = 65536 + + +def _fp_socket_reader(node_name: str, service_name: str, log_target: str) -> None: + sock_path = _os.environ.get("DECNET_FP_SOCK", "/run/decnet/fp.sock") + try: + sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM) + sock.bind(sock_path) + except OSError: + return + while True: + try: + data = sock.recv(_FP_BUF) + record = _json.loads(data) + except (OSError, ValueError): + continue + kind = record.get("kind", "") + remote = record.get("remote_addr", "-") + + if kind == "h2_settings": + ln = syslog_line( + service_name, node_name, "http2_settings", SEVERITY_INFO, + remote_addr=remote, + settings=_json.dumps(record.get("settings", {})), + frame_order=_json.dumps(record.get("frame_order", [])), + ) + write_syslog_file(ln) + if log_target: + forward_syslog(ln, log_target) + + elif kind == "h3_settings": + ln = syslog_line( + service_name, node_name, "http3_settings", SEVERITY_INFO, + remote_addr=remote, + settings=_json.dumps(record.get("settings", {})), + frame_order=_json.dumps(record.get("frame_order", [])), + ) + write_syslog_file(ln) + if log_target: + forward_syslog(ln, log_target) + + elif kind == "http_request_headers": + headers = record.get("headers_ordered", []) + method = record.get("method", "") + proto = record.get("proto_tag", "h1") + cookie = record.get("cookie", "") + accept_lang = record.get("accept_language", "") + ja4h = _compute_ja4h(method, proto, headers, cookie, accept_lang) + names_only = [ + (h[0].lower() if isinstance(h, (list, tuple)) else h.lower()) + for h in headers + ] + ln = syslog_line( + service_name, node_name, "http_request_fingerprint", SEVERITY_INFO, + remote_addr=remote, + proto=proto, + method=method, + path=record.get("path", ""), + ja4h=ja4h, + headers_ordered=_json.dumps(names_only), + cookie=cookie, + accept_language=accept_lang, + ) + write_syslog_file(ln) + if log_target: + forward_syslog(ln, log_target) + + elif kind == "access_log": + ln = syslog_line( + service_name, node_name, "http_access", SEVERITY_INFO, + remote_addr=remote, + method=record.get("method", ""), + path=record.get("path", ""), + proto=record.get("proto_tag", "-"), + status=str(record.get("status", 0)), + bytes=str(record.get("bytes", 0)), + ) + write_syslog_file(ln) + if log_target: + forward_syslog(ln, log_target) + + +def start_fp_socket_reader(node_name: str, service_name: str, log_target: str) -> None: + t = _threading.Thread( + target=_fp_socket_reader, + args=(node_name, service_name, log_target), + daemon=True, + ) + t.start() diff --git a/tests/service_testing/test_dns.py b/tests/service_testing/test_dns.py new file mode 100644 index 00000000..0a1f78ae --- /dev/null +++ b/tests/service_testing/test_dns.py @@ -0,0 +1,349 @@ +"""Tests for decnet/templates/dns/server.py and decnet/services/dns.py.""" + +import collections +import importlib.util +import struct +import sys +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +_SERVER_PATH = "decnet/templates/dns/server.py" + +# ── Test helpers ────────────────────────────────────────────────────────────── + +def _make_fake_syslog_bridge() -> ModuleType: + mod = ModuleType("syslog_bridge") + events: list[tuple[str, dict]] = [] + + def syslog_line(service, hostname, event_type, severity=6, **fields): + events.append((event_type, fields)) + return f"LOG {event_type}" + + mod.syslog_line = syslog_line + mod.write_syslog_file = MagicMock() + mod.forward_syslog = MagicMock() + mod.SEVERITY_INFO = 6 + mod.SEVERITY_WARNING = 4 + mod.encode_secret = MagicMock(return_value={"secret_printable": "", "secret_b64": ""}) + mod._events = events + return mod + + +def _make_fake_instance_seed() -> ModuleType: + import random as _random + mod = ModuleType("instance_seed") + mod.rng = _random.Random(42) + mod.pick = lambda choices: list(choices)[0] + mod.instance_uuid = lambda ns="": f"aaaabbbb-cccc-dddd-eeee-{ns[:12].ljust(12, '0')}" + mod.instance_hex = lambda nbytes, ns="": ("deadbeef" * 4)[:nbytes * 2] + mod.hostname = lambda: "testhost" + mod.jitter = MagicMock() + return mod + + +def _load_dns(extra_env: dict | None = None): + """Load server.py in isolation with mocked syslog_bridge and instance_seed.""" + env = { + "NODE_NAME": "testhost", + "DNS_ZONE_MODE": "auth", + "DNS_DOMAIN": "test.local", + "DNS_BIND_VERSION": "9.11.4-TEST", + "DNS_NSID": "testnsid", + "DNS_EXTRA_RECORDS": "", + **(extra_env or {}), + } + for key in list(sys.modules): + if key in ("dns_server", "syslog_bridge", "instance_seed"): + del sys.modules[key] + + bridge = _make_fake_syslog_bridge() + seed = _make_fake_instance_seed() + sys.modules["syslog_bridge"] = bridge + sys.modules["instance_seed"] = seed + + spec = importlib.util.spec_from_file_location("dns_server", _SERVER_PATH) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + with patch.dict("os.environ", env, clear=False): + spec.loader.exec_module(mod) # type: ignore[union-attr] + + # Reset tunneling state between tests + mod._txt_times.clear() + + return mod, bridge._events + + +def _build_query( + qname: str, + qtype: int, + qclass: int = 1, + qid: int = 0x1234, + rd: bool = True, +) -> bytes: + """Minimal DNS query wire packet.""" + flags = 0x0100 if rd else 0x0000 + header = struct.pack(">HHHHHH", qid, flags, 1, 0, 0, 0) + wire = b"" + for label in qname.rstrip(".").split("."): + enc = label.encode("ascii") + wire += bytes([len(enc)]) + enc + wire += b"\x00" + return header + wire + struct.pack(">HH", qtype, qclass) + + +def _rcode(data: bytes) -> int: + return struct.unpack_from(">H", data, 2)[0] & 0x0F + + +def _counts(data: bytes) -> tuple[int, int, int, int]: + _, _, qd, an, ns, ar = struct.unpack_from(">HHHHHH", data, 0) + return qd, an, ns, ar + + +def _events_of(events: list, kind: str) -> list[dict]: + return [fields for etype, fields in events if etype == kind] + +# ── Auth zone ───────────────────────────────────────────────────────────────── + +class TestAuthZone: + def test_a_record_apex(self): + mod, events = _load_dns() + resp = mod._handle(_build_query("test.local", mod.TYPE_A), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + _, ancount, _, _ = _counts(resp) + assert ancount >= 1 + assert _events_of(events, "query") + + def test_a_record_www(self): + mod, events = _load_dns() + resp = mod._handle(_build_query("www.test.local", mod.TYPE_A), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + _, ancount, _, _ = _counts(resp) + assert ancount >= 1 + + def test_nxdomain_unknown_name(self): + mod, _ = _load_dns() + resp = mod._handle(_build_query("nobody.test.local", mod.TYPE_A), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NXDOMAIN + + def test_out_of_zone_refused_in_auth_mode(self): + mod, _ = _load_dns({"DNS_ZONE_MODE": "auth"}) + resp = mod._handle(_build_query("google.com", mod.TYPE_A), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_REFUSED + + def test_soa_record(self): + mod, events = _load_dns() + resp = mod._handle(_build_query("test.local", mod.TYPE_SOA), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + _, ancount, _, _ = _counts(resp) + assert ancount >= 1 + + def test_mx_record(self): + mod, events = _load_dns() + resp = mod._handle(_build_query("test.local", mod.TYPE_MX), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + + def test_extra_records_parsed(self): + mod, events = _load_dns({"DNS_EXTRA_RECORDS": "extra A 192.168.0.50"}) + resp = mod._handle(_build_query("extra.test.local", mod.TYPE_A), "1.2.3.4", 1234, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + +# ── Fingerprint probes ──────────────────────────────────────────────────────── + +class TestFingerprintProbe: + def test_version_bind_returns_configured_banner(self): + mod, events = _load_dns() + query = _build_query("version.bind", mod.TYPE_TXT, qclass=mod.CLASS_CH) + resp = mod._handle(query, "10.0.0.1", 12345, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_NOERROR + _, ancount, _, _ = _counts(resp) + assert ancount == 1 + probes = _events_of(events, "fingerprint_probe") + assert probes + assert probes[0]["probe"] == "version.bind" + assert probes[0]["response"] == "9.11.4-TEST" + + def test_hostname_bind_emits_fingerprint_probe(self): + mod, events = _load_dns() + query = _build_query("hostname.bind", mod.TYPE_TXT, qclass=mod.CLASS_CH) + resp = mod._handle(query, "10.0.0.1", 12345, "udp") + assert resp is not None + assert _events_of(events, "fingerprint_probe") + + def test_id_server_emits_fingerprint_probe(self): + mod, events = _load_dns() + query = _build_query("id.server", mod.TYPE_TXT, qclass=mod.CLASS_CH) + resp = mod._handle(query, "10.0.0.1", 12345, "udp") + assert resp is not None + assert _events_of(events, "fingerprint_probe") + + def test_unknown_chaos_is_refused_still_logged(self): + mod, events = _load_dns() + query = _build_query("something.chaos", mod.TYPE_TXT, qclass=mod.CLASS_CH) + resp = mod._handle(query, "10.0.0.1", 12345, "udp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_REFUSED + assert _events_of(events, "fingerprint_probe") + + def test_no_query_event_for_fingerprint(self): + mod, events = _load_dns() + query = _build_query("version.bind", mod.TYPE_TXT, qclass=mod.CLASS_CH) + mod._handle(query, "10.0.0.1", 12345, "udp") + assert not _events_of(events, "query") + +# ── Zone transfer ───────────────────────────────────────────────────────────── + +class TestZoneTransfer: + def test_axfr_refused_and_logged(self): + mod, events = _load_dns() + query = _build_query("test.local", mod.TYPE_AXFR) + resp = mod._handle(query, "5.5.5.5", 9999, "tcp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_REFUSED + xfers = _events_of(events, "zone_transfer") + assert xfers + assert xfers[0]["qtype"] == "AXFR" + assert xfers[0]["transport"] == "tcp" + + def test_ixfr_refused_and_logged(self): + mod, events = _load_dns() + query = _build_query("test.local", mod.TYPE_IXFR) + resp = mod._handle(query, "5.5.5.5", 9999, "tcp") + assert resp is not None + assert _rcode(resp) == mod.RCODE_REFUSED + xfers = _events_of(events, "zone_transfer") + assert xfers + assert xfers[0]["qtype"] == "IXFR" + +# ── Amp probes ──────────────────────────────────────────────────────────────── + +class TestAmpProbe: + def test_qtype_any_emits_amp_probe(self): + mod, events = _load_dns() + query = _build_query("test.local", mod.TYPE_ANY) + resp = mod._handle(query, "2.2.2.2", 5353, "udp") + assert resp is not None + assert _events_of(events, "amp_probe") + + def test_amp_probe_suppresses_plain_query_event(self): + mod, events = _load_dns() + query = _build_query("test.local", mod.TYPE_ANY) + mod._handle(query, "2.2.2.2", 5353, "udp") + assert not _events_of(events, "query") + +# ── Tunneling heuristic ─────────────────────────────────────────────────────── + +class TestTunnelingHeuristic: + def test_long_high_entropy_label(self): + mod, events = _load_dns() + # 40-char high-entropy label (mix of alpha + digits) + label = "abcdefghijklmnopqrstuvwxyz0123456789abcd" + assert len(label) >= mod._LABEL_LEN_THRESHOLD + query = _build_query(f"{label}.test.local", mod.TYPE_A) + resp = mod._handle(query, "9.9.9.9", 1234, "udp") + assert resp is not None + assert _events_of(events, "tunneling_suspect") + + def test_rapid_txt_burst_triggers_tunneling(self): + mod, events = _load_dns() + src = "3.3.3.3" + # 5 TXT queries in rapid succession triggers the burst heuristic + for i in range(5): + query = _build_query(f"chunk{i}.test.local", mod.TYPE_TXT) + mod._handle(query, src, 1234, "udp") + assert _events_of(events, "tunneling_suspect") + + def test_tunneling_suppresses_plain_query_event(self): + mod, events = _load_dns() + label = "abcdefghijklmnopqrstuvwxyz0123456789abcd" + query = _build_query(f"{label}.test.local", mod.TYPE_A) + mod._handle(query, "9.9.9.9", 1234, "udp") + assert not _events_of(events, "query") + +# ── Zone mode: open ─────────────────────────────────────────────────────────── + +class TestZoneModeOpen: + def test_open_mode_resolves_any_name(self): + mod, _ = _load_dns({"DNS_ZONE_MODE": "open"}) + for qname in ("evil.example.com", "c2.attacker.net", "random.io"): + query = _build_query(qname, mod.TYPE_A) + resp = mod._handle(query, "4.4.4.4", 1234, "udp") + assert resp is not None, f"no response for {qname}" + assert _rcode(resp) == mod.RCODE_NOERROR + _, ancount, _, _ = _counts(resp) + assert ancount >= 1 + + def test_open_mode_returns_loopback_sinkhole(self): + mod, _ = _load_dns({"DNS_ZONE_MODE": "open"}) + # The sinkhole A record must be in 127.0.0.0/8 + query = _build_query("anything.com", mod.TYPE_A) + resp = mod._handle(query, "4.4.4.4", 1234, "udp") + assert resp is not None + # Find the A RDATA — walk past header(12) + question + answer name + # Just verify the response contains 127 somewhere in a 4-byte window + assert b"\x7f" in resp # 0x7f = 127 + +# ── Zone mode: recursive ────────────────────────────────────────────────────── + +class TestZoneModeRecursive: + def test_recursive_mode_sets_ra_flag(self): + mod, _ = _load_dns({"DNS_ZONE_MODE": "recursive"}) + query = _build_query("out-of-zone.example.com", mod.TYPE_A) + resp = mod._handle(query, "1.1.1.1", 1234, "udp") + assert resp is not None + flags = struct.unpack_from(">H", resp, 2)[0] + ra = bool(flags & 0x0080) + assert ra + +# ── Service registration ────────────────────────────────────────────────────── + +class TestServiceRegistration: + def test_dns_registered_by_name(self): + from decnet.services.registry import get_service + svc = get_service("dns") + assert svc is not None + assert svc.name == "dns" + + def test_dns_port_53(self): + from decnet.services.registry import get_service + svc = get_service("dns") + assert 53 in svc.ports + + def test_dns_udp_ports(self): + from decnet.services.registry import get_service + svc = get_service("dns") + assert 53 in svc.udp_ports() + + def test_compose_fragment_structure(self): + from decnet.services.registry import get_service + svc = get_service("dns") + frag = svc.compose_fragment("decky-01", log_target="127.0.0.1:514") + assert "build" in frag + assert frag["container_name"] == "decky-01-dns" + assert frag["environment"]["NODE_NAME"] == "decky-01" + assert frag["environment"]["LOG_TARGET"] == "127.0.0.1:514" + assert "DNS_ZONE_MODE" in frag["environment"] + assert "DNS_BIND_VERSION" in frag["environment"] + + def test_compose_fragment_no_log_target(self): + from decnet.services.registry import get_service + svc = get_service("dns") + frag = svc.compose_fragment("decky-02") + assert "LOG_TARGET" not in frag["environment"] + + def test_dockerfile_context_points_to_template(self): + from decnet.services.registry import get_service + svc = get_service("dns") + ctx = svc.dockerfile_context() + assert ctx is not None + assert ctx.name == "dns" + assert (ctx / "Dockerfile").exists()