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).
This commit is contained in:
Ryan Malloy 2026-05-20 18:40:11 -06:00
parent 94f2bdc68a
commit 618e9504e7
9 changed files with 276 additions and 22 deletions

View file

@ -1,9 +1,17 @@
#!/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
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.
@ -26,10 +34,17 @@ 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)
# (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
@ -112,21 +127,21 @@ def main() -> int:
successes = failures = 0
for zone in zones:
zone_oks = []
for ns in HE_NAMESERVERS:
ok, status = send_notify(zone, ns)
for ip, label in NOTIFY_TARGETS:
ok, status = send_notify(zone, ip)
if ok:
zone_oks.append(ns)
zone_oks.append(label)
successes += 1
else:
if not quiet:
print(f" ✗ {zone:35s} → {ns:15s} {status}")
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(HE_NAMESERVERS)} HE ns")
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(HE_NAMESERVERS)} nameservers"
f"across {len(zones)} zones × {len(NOTIFY_TARGETS)} targets"
)
return 0 if failures == 0 else 2