#!/usr/bin/env python3 """ Send DNS NOTIFY messages (RFC 1996) to every public secondary that slaves our zones, telling them to re-poll immediately rather than waiting for the next SOA-refresh cycle (up to 1 hour). Currently two targets: 1. Hurricane Electric (ns1.he.net) — free secondary service slaving ~11 of our 91 zones. One NOTIFY to ns1 wakes their whole anycast pool internally. 2. ns.supported.systems — our own CoreDNS secondary, slaves all zones from dell01 hidden primary. Added once the FortiWiFi was opened for AXFR from 64.177.113.227. This replicates what CoreDNS's `transfer { to }` directive would do natively, but as an external script because that directive silently breaks server-block startup on CoreDNS 1.11.3 + 1.12.2 in our config. Called automatically from `make prep`. No dependencies beyond Python 3 stdlib — we craft the 12-byte DNS header + question section by hand. NOTIFY semantics: - QR=0 (query), Opcode=4 (NOTIFY), AA=1 (we're authoritative) - QDCOUNT=1, question = SOA IN - Slave responds with NOERROR + similar header, then issues AXFR/SOA queries to see if the zone has actually changed. """ from __future__ import annotations import os import random import socket import struct import sys from pathlib import Path # (ip, label) — label is informational, appears in log lines. # Order doesn't matter; NOTIFY is fire-and-forget per target. NOTIFY_TARGETS: list[tuple[str, str]] = [ ("216.218.130.2", "ns1.he.net"), # HE's slave cluster replicates internally; one NOTIFY here wakes # the whole public anycast pool. ("64.177.113.227", "ns.supported.systems"), # Our own public secondary running CoreDNS in Docker. AXFRs all # 84 zones from dell01. Source-IP-authorized on the secondary side # via `transfer from 154.27.180.210` (dell01's NAT egress IP). ] DNS_PORT = 53 TIMEOUT_SECONDS = 5 def encode_name(name: str) -> bytes: """Encode a domain name as length-prefixed labels + null terminator.""" out = b"" for label in name.rstrip(".").split("."): if len(label) > 63: raise ValueError(f"DNS label too long: {label}") out += bytes([len(label)]) + label.encode("ascii") return out + b"\x00" def build_notify(zone: str) -> bytes: """Build a DNS NOTIFY message for the given zone.""" txid = random.randint(0, 0xFFFF) # Flags: QR=0, Opcode=4 (NOTIFY), AA=1, TC=0, RD=0, RA=0, Z=0, RCODE=0 # Layout: 0 0100 1 000 0 000 0000 → 0x2400 flags = (0 << 15) | (4 << 11) | (1 << 10) | 0 header = struct.pack( ">HHHHHH", txid, flags, 1, # QDCOUNT 0, # ANCOUNT 0, # NSCOUNT 0, # ARCOUNT ) qname = encode_name(zone) qtype = struct.pack(">H", 6) # SOA qclass = struct.pack(">H", 1) # IN return header + qname + qtype + qclass def send_notify(zone: str, server: str) -> tuple[bool, str]: """Send NOTIFY for zone to server. Returns (ok, status_str).""" pkt = build_notify(zone) try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.settimeout(TIMEOUT_SECONDS) s.sendto(pkt, (server, DNS_PORT)) data, _ = s.recvfrom(512) if len(data) < 12: return False, "short response" # Parse flags from response header _, rflags, _, _, _, _ = struct.unpack(">HHHHHH", data[:12]) opcode = (rflags >> 11) & 0xF rcode = rflags & 0xF if opcode != 4: return False, f"opcode={opcode}" if rcode != 0: return False, f"rcode={rcode}" return True, "ack" except socket.timeout: return False, "timeout" except OSError as e: return False, f"err: {e}" def discover_zones(prepared_dir: Path) -> list[str]: """Return zone names from prepared zone filenames (foo.zone -> foo).""" return sorted(f.stem for f in prepared_dir.glob("*.zone")) def main() -> int: prepared = Path(os.environ.get("DST_DIR", "zones-prepared")) if not prepared.is_dir(): print(f"ERROR: prepared dir {prepared} not found", file=sys.stderr) return 1 zones = discover_zones(prepared) if not zones: print(f"ERROR: no zones in {prepared}", file=sys.stderr) return 1 quiet = "--quiet" in sys.argv successes = failures = 0 for zone in zones: zone_oks = [] for ip, label in NOTIFY_TARGETS: ok, status = send_notify(zone, ip) if ok: zone_oks.append(label) successes += 1 else: if not quiet: print(f" ✗ {zone:35s} → {label:24s} ({ip:15s}) {status}") failures += 1 if zone_oks and not quiet: print(f" ✓ {zone:35s} → {len(zone_oks)}/{len(NOTIFY_TARGETS)} targets") print( f"NOTIFY summary: {successes} acks, {failures} fails " f"across {len(zones)} zones × {len(NOTIFY_TARGETS)} targets" ) return 0 if failures == 0 else 2 if __name__ == "__main__": sys.exit(main())