#!/usr/bin/env python3 """ Send DNS NOTIFY messages (RFC 1996) to Hurricane Electric's secondary nameservers, telling them to re-poll our zones immediately rather than waiting for the next SOA-refresh cycle (up to 1 hour). 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 HE_NAMESERVERS = [ "216.218.130.2", # ns1.he.net — the NOTIFY-accepting endpoint # (HE's slave cluster replicates internally; one # NOTIFY here wakes the whole pool) ] 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 ns in HE_NAMESERVERS: ok, status = send_notify(zone, ns) if ok: zone_oks.append(ns) successes += 1 else: if not quiet: print(f" ✗ {zone:35s} → {ns:15s} {status}") failures += 1 if zone_oks and not quiet: print(f" ✓ {zone:35s} → {len(zone_oks)}/{len(HE_NAMESERVERS)} HE ns") print( f"NOTIFY summary: {successes} acks, {failures} fails " f"across {len(zones)} zones × {len(HE_NAMESERVERS)} nameservers" ) return 0 if failures == 0 else 2 if __name__ == "__main__": sys.exit(main())