2026-05-18 16:57:54 -06:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-05-20 18:40:11 -06:00
|
|
|
|
Send DNS NOTIFY messages (RFC 1996) to every public secondary that
|
|
|
|
|
|
slaves our zones, telling them to re-poll immediately rather than
|
2026-05-18 16:57:54 -06:00
|
|
|
|
waiting for the next SOA-refresh cycle (up to 1 hour).
|
|
|
|
|
|
|
2026-05-20 18:40:11 -06:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-05-18 16:57:54 -06:00
|
|
|
|
This replicates what CoreDNS's `transfer { to <IP> }` 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 = <zone> 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
|
|
|
|
|
|
|
2026-05-20 18:40:11 -06:00
|
|
|
|
# (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).
|
2026-05-18 16:57:54 -06:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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 = []
|
2026-05-20 18:40:11 -06:00
|
|
|
|
for ip, label in NOTIFY_TARGETS:
|
|
|
|
|
|
ok, status = send_notify(zone, ip)
|
2026-05-18 16:57:54 -06:00
|
|
|
|
if ok:
|
2026-05-20 18:40:11 -06:00
|
|
|
|
zone_oks.append(label)
|
2026-05-18 16:57:54 -06:00
|
|
|
|
successes += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
if not quiet:
|
2026-05-20 18:40:11 -06:00
|
|
|
|
print(f" ✗ {zone:35s} → {label:24s} ({ip:15s}) {status}")
|
2026-05-18 16:57:54 -06:00
|
|
|
|
failures += 1
|
|
|
|
|
|
if zone_oks and not quiet:
|
2026-05-20 18:40:11 -06:00
|
|
|
|
print(f" ✓ {zone:35s} → {len(zone_oks)}/{len(NOTIFY_TARGETS)} targets")
|
2026-05-18 16:57:54 -06:00
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"NOTIFY summary: {successes} acks, {failures} fails "
|
2026-05-20 18:40:11 -06:00
|
|
|
|
f"across {len(zones)} zones × {len(NOTIFY_TARGETS)} targets"
|
2026-05-18 16:57:54 -06:00
|
|
|
|
)
|
|
|
|
|
|
return 0 if failures == 0 else 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|