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