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:
2026-05-21 19:07:49 -04:00
parent 72cdeb3270
commit 77a466e615
7 changed files with 1533 additions and 0 deletions

83
decnet/services/dns.py Normal file
View File

@@ -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: <name> <TYPE> <value>",
),
]
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]

View File

@@ -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"]

View File

@@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec python3 /opt/server.py

View File

@@ -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)

View 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())

View File

@@ -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:
<PRI>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()

View File

@@ -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()