#!/usr/bin/env python3 """Local DNS-copy exercise. No registrar, public resolver or SMTP traffic.""" from pathlib import Path from tempfile import TemporaryDirectory from datetime import datetime, timezone import hashlib import json import re import shutil import socket import subprocess import threading ZONE = "agency.test." MAIL = { ("@", "MX"): "10 mail.agency.test.", ("mail", "A"): "192.0.2.25", ("mail", "AAAA"): "2001:db8::25", ("@", "TXT"): '"v=spf1 ip4:192.0.2.25 -all"', ("selector1._domainkey", "CNAME"): "selector1.mail.test.", ("_dmarc", "TXT"): '"v=DMARC1; p=reject; rua=mailto:dmarc@agency.test"', ("autodiscover", "CNAME"): "autodiscover.mail.test.", ("_mta-sts", "TXT"): '"v=STSv1; id=20260928"', ("mta-sts", "A"): "192.0.2.26", ("_smtp._tls", "TXT"): '"v=TLSRPTv1; rua=mailto:tls@agency.test"', } QUERIES = [*MAIL, ("@", "A"), ("@", "NS")] def command(args): return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=20) def snapshot(parent, label, web, ns, broken=False): root = parent / label root.mkdir() records = dict(MAIL) if broken: records.pop(("selector1._domainkey", "CNAME")) records[("@", "MX")] = "10 wrong.mail.test." zone = "$ORIGIN agency.test.\n$TTL 300\n" zone += f"@ IN SOA {ns}.agency.test. hostmaster.agency.test. (1 3600 600 86400 300)\n" zone += f"@ IN NS {ns}.agency.test.\n{ns} IN A 192.0.2.53\n@ IN A {web}\n" zone += "".join(f"{name} IN {kind} {value}\n" for (name, kind), value in records.items()) zone_file = root / "zone.db" zone_file.write_text(zone) command(["named-checkzone", ZONE, str(zone_file)]) with socket.socket() as port_reservation: port_reservation.bind(("127.0.0.1", 0)) port = port_reservation.getsockname()[1] config = root / "named.conf" config.write_text(f'''options {{ directory "{root}"; listen-on port {port} {{ 127.0.0.1; }}; listen-on-v6 {{ none; }}; recursion no; dnssec-validation no; empty-zones-enable no; notify no; pid-file none; session-keyfile none; }}; controls {{ }}; zone "agency.test" {{ type primary; file "{zone_file}"; }}; ''') command(["named-checkconf", str(config)]) process = subprocess.Popen( ["named", "-g", "-n", "1", "-c", str(config)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) ready = threading.Event() startup = [] def read_log(): for line in process.stdout: startup.append(line) if re.search(r"\brunning\s*$", line): ready.set() reader = threading.Thread(target=read_log, daemon=True) reader.start() try: # Startup hang guard only. No DNS/cache assertion depends on elapsed time. if not ready.wait(15): raise RuntimeError("named did not become ready: " + "".join(startup)) answers = {} for name, kind in QUERIES: fqdn = ZONE if name == "@" else name + "." + ZONE output = command(["dig", "-r", "@127.0.0.1", "-p", str(port), fqdn, kind, "+tcp", "+norecurse", "+noall", "+comments", "+answer"]) if "status: NOERROR" not in output and "status: NXDOMAIN" not in output: raise RuntimeError(output) if not re.search(r"flags: [^;]*\baa\b", output): raise RuntimeError("Non-authoritative answer: " + output) rows = [] for line in output.splitlines(): if not line or line.startswith(";"): continue owner, ttl, klass, rtype, value = line.split(None, 4) if owner == fqdn and klass == "IN" and rtype == kind: rows.append(value) answers[f"{name} {kind}"] = sorted(rows) return {"answers": answers, "zone_sha256": hashlib.sha256(zone.encode()).hexdigest()} finally: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() reader.join(timeout=5) process.stdout.close() def main(): for tool in ["named", "dig", "named-checkzone", "named-checkconf"]: if not shutil.which(tool): raise SystemExit(f"Missing prerequisite: {tool}; see README.md") versions = {tool: command([tool, "-v"]).strip() for tool in ["named", "dig"]} with TemporaryDirectory(prefix="obhut-dns-copy-") as temp: root = Path(temp).resolve() old = snapshot(root, "old", "192.0.2.10", "ns-old") broken = snapshot(root, "broken", "198.51.100.10", "ns-new", broken=True) fixed = snapshot(root, "fixed", "198.51.100.10", "ns-new") checks = [] def check(name, condition): checks.append({"name": name, "passed": bool(condition)}) if not condition: raise AssertionError(name) for (name, kind), expected in MAIL.items(): key = f"{name} {kind}" check("baseline: " + key, old["answers"][key] == [expected]) check("preserved: " + key, fixed["answers"][key] == old["answers"][key]) check("missing DKIM selector detected", broken["answers"]["selector1._domainkey CNAME"] == []) check("wrong MX detected", broken["answers"]["@ MX"] != old["answers"]["@ MX"]) check("website address changed", old["answers"]["@ A"] == ["192.0.2.10"] and fixed["answers"]["@ A"] == ["198.51.100.10"]) check("nameserver changed", old["answers"]["@ NS"] == ["ns-old.agency.test."] and fixed["answers"]["@ NS"] == ["ns-new.agency.test."]) print(json.dumps({ "observed_at": datetime.now(timezone.utc).isoformat(), "scope": "Local BIND authoritative DNS answers over TCP; synthetic agency.test zone", "limitations": ["No public delegation or resolver cache test", "No DNSSEC validation", "No SMTP, SPF/DKIM/DMARC evaluation or mail delivery", "No TLS or MTA-STS policy fetch"], "versions": versions, "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "snapshots": {"old": old, "broken": broken, "fixed": fixed}, "checks": checks, "passed": len(checks), }, indent=2) + "\n") if __name__ == "__main__": main()