coredns/scripts/notify-secondaries.py
Ryan Malloy 618e9504e7 secondary: scaffold public CoreDNS secondary on ns.supported.systems
Adds a second non-HE public secondary that pulls AXFR from dell01 (the
hidden primary at 154.27.180.210) and answers public queries on
ns.supported.systems (64.177.113.227, 2001:19f0:5c00:4daa:5400:6ff:fe2d:38fa).

  secondary/
    Corefile                            generated, 84 zones + REFUSED catch-all
    docker-compose.yml                  CoreDNS in host-net mode
    Makefile                            up/down/logs/regen/test/axfr-test
    .env / .env.example                 image pin + bind IPs
    scripts/generate-secondary-corefile.sh  reads ../zones/*.zone

  scripts/notify-he.py → notify-secondaries.py
                                        adds 64.177.113.227 as a second
                                        NOTIFY target alongside HE's
                                        216.218.130.2

Uses CoreDNS's `bind` plugin to avoid colliding with systemd-resolved
on loopback :53. Authoritative-only — non-listed zones get REFUSED, no
recursion. AXFR pull requires opening TCP/53 on dell01's FortiWiFi for
the secondary's IP (manual step, separate from this commit).
2026-05-20 18:40:11 -06:00

151 lines
5.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <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
# (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())