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

@ -10,7 +10,7 @@ what the public actually sees. Git/this repo is the source of truth.
```
edit zones/*.zone → make prep → CoreDNS auto-reloads (30s)
scripts/notify-he.py
scripts/notify-secondaries.py
NOTIFY → ns1.he.net (216.218.130.2)
@ -53,7 +53,7 @@ git add -A && git commit -m "homestar.ink: add foo A 1.2.3.4"
```
Wait ≤5 minutes for HE to AXFR. If serial doesn't flip on HE,
re-run NOTIFY: `ssh -A dell01... 'cd ~/coredns && ./scripts/notify-he.py'`
re-run NOTIFY: `ssh -A dell01... 'cd ~/coredns && ./scripts/notify-secondaries.py'`
## Publishing to dell01
@ -97,7 +97,7 @@ The Docker stack: `coredns` (server) + `coredns-caddy` (LE cert for
## NOTIFY: external script, not CoreDNS-native
We use `scripts/notify-he.py` to send NOTIFY messages to
We use `scripts/notify-secondaries.py` to send NOTIFY messages to
`216.218.130.2` (ns1.he.net) on every `make prep`. Pure stdlib Python,
no deps.
@ -112,11 +112,11 @@ Only `transfer { to * }` works.
So:
- `Corefile`: `transfer { to * }` — open AXFR (firewall does the
source-IP filtering on TCP/53 NAT anyway)
- `notify-he.py`: sends NOTIFY explicitly to the right IP
- `notify-secondaries.py`: sends NOTIFY explicitly to each secondary's IP
NOTIFY happens automatically on `make prep`. To NOTIFY manually:
```bash
ssh -A dell01... 'cd ~/coredns && ./scripts/notify-he.py'
ssh -A dell01... 'cd ~/coredns && ./scripts/notify-secondaries.py'
```
The script's output doubles as a **"what's on HE" inventory** — `✓`
@ -125,7 +125,7 @@ for zones HE hosts, `✗ rcode=5` for zones HE doesn't yet host.
**HE's NOTIFY behavior**: HE acks NOTIFY at the protocol level (rcode=0),
and *usually* triggers an immediate AXFR. Sometimes the batch NOTIFY
fired from `make prep` doesn't seem to wake them; re-running
`notify-he.py` manually almost always does. Per-zone NOTIFY is more
`notify-secondaries.py` manually almost always does. Per-zone NOTIFY is more
reliable than batch.
## HE asymmetric IPs
@ -196,7 +196,7 @@ Intermediate empty non-terminals **do** block synthesis below them.
## Zone-by-zone HE status
`./scripts/notify-he.py` prints `✓` / `✗` per zone — `✓` means HE
`./scripts/notify-secondaries.py` prints `✓` / `✗` per zone — `✓` means HE
hosts that zone as a secondary, `✗` (rcode=5) means HE doesn't yet
host it. As of the last NOTIFY run, ~11 of 91 zones are slaved on HE.
The other 80 are still served from Vultr at the registrar level.
@ -228,7 +228,8 @@ Renewal happens automatically; Caddy uses ACME ARI to schedule it.
| `zones-prepared/*.zone` | Generated, served by CoreDNS (gitignored) |
| `Corefile` | CoreDNS config |
| `scripts/prepare-zones.sh` | Zone prep + auto-bump serial |
| `scripts/notify-he.py` | Send NOTIFY to ns1.he.net |
| `scripts/notify-secondaries.py` | Send NOTIFY to ns1.he.net + ns.supported.systems |
| `secondary/` | Public secondary (CoreDNS in Docker) deployed to ns.supported.systems |
| `scripts/check-he.sh` | Parallel HE anycast verification |
| `caddy/Caddyfile` + `caddy/Dockerfile` | Caddy sidecar config |
| `docker-compose.yml` | CoreDNS + Caddy stack |

View File

@ -12,9 +12,9 @@ export
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
prep: ## Re-inject SOA + bump serial, then NOTIFY HE (auto-fires AXFR)
prep: ## Re-inject SOA + bump serial, then NOTIFY all secondaries (auto-fires AXFR)
@./scripts/prepare-zones.sh
@./scripts/notify-he.py --quiet || echo " (NOTIFY had failures; HE will still re-poll on SOA refresh)"
@./scripts/notify-secondaries.py --quiet || echo " (NOTIFY had failures; secondaries will still re-poll on SOA refresh)"
certs: ## Generate self-signed dev cert (only useful if not using Caddy ACME)
@./scripts/generate-certs.sh

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

12
secondary/.env.example Normal file
View File

@ -0,0 +1,12 @@
COMPOSE_PROJECT_NAME=coredns-secondary
# CoreDNS image — match the primary's pin for consistency.
COREDNS_IMAGE=coredns/coredns:1.11.3
# Public addresses this secondary advertises and listens on. Leave both
# empty to bind every interface (the default). Set them when another
# process already owns loopback :53 (e.g. systemd-resolved). The
# generator script reads these and emits a `bind` directive in the
# Corefile when either is set.
BIND_V4=64.177.113.227
BIND_V6=2001:19f0:5c00:4daa:5400:06ff:fe2d:38fa

3
secondary/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Runtime-only — never commit
.env
.env.local

29
secondary/Corefile Normal file
View File

@ -0,0 +1,29 @@
# AUTO-GENERATED by secondary/scripts/generate-secondary-corefile.sh
# Source: /home/rpm/claude/coredns/zones/*.zone (84 zones)
# Re-generated: 2026-05-20T18:37:08-06:00
# DO NOT EDIT BY HAND — re-run the generator instead.
# Public secondary for 84 zones. Pulls AXFR/IXFR from
# 154.27.180.210 (dell01 hidden primary) and serves the public face.
# Inbound NOTIFY from the same IP triggers immediate re-poll.
acrazy.org. automaton.global. automaton.host. blender.bet. blender.cam. blender.partners. blender.quest. blender.systems. cloud-dine.com. context.bet. coopermalloy.com. copper-springs.online. cyberinsuranceapp.com. demostar.app. demostar.click. demostar.io. demostar.net. demo-tube.com. dignity.ink. dope.team. encom.cash. encom.ink. encom.website. encom.wtf. enls.us. enls.video. freemyradicals.com. garage.ceo. garage.christmas. garage.doctor. garage.dog. garage.engineering. garage.makeup. garage.rocks. garage.supply. glennsferry.site. home-inspector.app. home-inspector.pics. home-inspector.site. home-inspector.store. home-inspector.website. homestar.ink. inpect.pro. inspect.monster. inspect.pics. inspects.homes. inspect.systems. jobsite.homes. kg7q.cc. log.doctor. lukascrockett.com. malloys.us. mcpdash.wtf. mcp.website. myhood.us. nielsen-inspections.com. nielsens.world. ourjob.site. paigemalloy.com. paythatway.com. powdercoatedcabinents.com. powdercoatedcabinet.com. powdercotedcabinets.com. prezhub.com. reviewr.guru. rsvp-for.de. ryanmalloy.com. screencast.systems. septic.report. sidejob.pro. spencernewbolt.com. supported.systems. supportedsystems.com. supportedsystems.net. syslog.chat. tatemalloy.com. tateorrtot.games. timber.ink. trackfeeds.cloud. tuckermalloy.com. upc.llc. warehack.ing. westboise.org. zmesh.systems. {
bind 64.177.113.227 2001:19f0:5c00:4daa:5400:06ff:fe2d:38fa
secondary {
transfer from 154.27.180.210
}
log
errors
# No `cache` plugin — authoritative answers don't need it
# and caching authoritative responses muddies TTL semantics.
}
# Catch-all block: anything outside the authoritative zone list
# returns REFUSED. We're not a recursive resolver — public clients
# asking us to recurse get an explicit no.
. {
bind 64.177.113.227 2001:19f0:5c00:4daa:5400:06ff:fe2d:38fa
errors
log
# No plugins that answer — empty chain → REFUSED.
# (The `errors` + `log` plugins record the attempt for visibility.)
}

52
secondary/Makefile Normal file
View File

@ -0,0 +1,52 @@
.DEFAULT_GOAL := help
SHELL := /usr/bin/env bash
COMPOSE := docker compose
# Pull COREDNS_IMAGE and friends into the recipe env. The .env file is
# also auto-loaded by `docker compose` itself, but `include` makes the
# values available in shell snippets within Makefile recipes too.
include .env
export
.PHONY: help regen up down restart logs ps test axfr-test
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
regen: ## Re-generate Corefile from ../zones/*.zone
@./scripts/generate-secondary-corefile.sh
up: ## Start the secondary
$(COMPOSE) up -d
@sleep 2 && $(COMPOSE) logs --tail=20 coredns
down: ## Stop & remove containers
$(COMPOSE) down
restart: ## Restart CoreDNS (does NOT regen Corefile)
$(COMPOSE) restart coredns
logs: ## Tail CoreDNS logs
$(COMPOSE) logs -f coredns
ps: ## Show container status
$(COMPOSE) ps
# ---------------------------------------------------------------------------
# Smoke tests — these query the LOCAL CoreDNS (this machine)
# ---------------------------------------------------------------------------
test: ## Smoke-test plain DNS against the local secondary
@echo "=== SOA for supported.systems (should match dell01 + HE) ==="
@dig @127.0.0.1 supported.systems SOA +short +tries=1 +time=3
@echo "=== NS records for supported.systems ==="
@dig @127.0.0.1 supported.systems NS +short +tries=1 +time=3
@echo "=== A record for ns.supported.systems (the glue) ==="
@dig @127.0.0.1 ns.supported.systems A +short +tries=1 +time=3
axfr-test: ## Verify AXFR pull from dell01 works (TCP/53 to primary)
@echo "Probing TCP/53 reachability to dell01 (154.27.180.210)..."
@timeout 5 bash -c "</dev/tcp/154.27.180.210/53" && echo " ✓ TCP/53 open" \
|| (echo " ✗ TCP/53 BLOCKED — add this server's IP to dell01's FortiWiFi allow list" && exit 1)
@echo "Attempting AXFR for supported.systems..."
@dig @154.27.180.210 supported.systems AXFR +tcp +tries=1 +time=8 +short | head -5

View File

@ -0,0 +1,33 @@
services:
coredns:
image: ${COREDNS_IMAGE}
container_name: coredns-secondary
restart: unless-stopped
command: ["-conf", "/etc/coredns/Corefile"]
# Host networking is required for two reasons:
#
# 1. CoreDNS's `secondary` plugin authorizes inbound NOTIFY by
# source IP (`transfer from 154.27.180.210`). With bridge
# networking, Docker's userland proxy rewrites the visible
# source to a 172.x docker0 address, and the auth check fails.
#
# 2. AXFR responses can be large; UDP/TCP source-IP preservation
# matters for both directions of the conversation.
#
# Host networking means this container OWNS host ports 53/udp + 53/tcp.
# On a dedicated NS box that's exactly what you want.
network_mode: host
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
# CoreDNS's distroless image has no shell, so the conventional
# `wget /health` healthcheck silently fails. The `-version` flag
# exits 0 only if the binary is runnable — a thin but honest probe.
healthcheck:
test: ["CMD", "/coredns", "-version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Generate secondary/Corefile from the master zone list (../zones/*.zone).
#
# This server is a public-facing secondary — it pulls AXFR from dell01
# (the hidden primary) and answers public queries. It is NOT recursive
# and NOT a forwarder; queries for any zone outside the generated list
# return REFUSED, which is correct behavior for an authoritative-only NS.
#
# Re-run this script whenever a zone is added to or removed from
# zones/. It is idempotent — same input always produces the same Corefile.
#
# Source-of-truth precedence: zones/ in the parent repo is the canonical
# list. zones-prepared/ on dell01 is a derived artifact; we don't read
# from it to avoid coupling the secondary to dell01's prep state.
set -euo pipefail
# Resolve relative to this script's location, not $PWD.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECONDARY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${SECONDARY_DIR}/.." && pwd)"
ZONES_DIR="${ZONES_DIR:-${REPO_ROOT}/zones}"
COREFILE="${COREFILE:-${SECONDARY_DIR}/Corefile}"
# dell01's public IP — the address CoreDNS will send AXFR/IXFR requests
# to and the source IP of inbound NOTIFY (dell01 NATs through this).
PRIMARY_IP="${PRIMARY_IP:-154.27.180.210}"
# Per-deployment bind addresses. When set, the generated Corefile uses
# CoreDNS's `bind` plugin to listen ONLY on these IPs. Required on hosts
# where another service already binds loopback :53 (e.g. systemd-resolved
# on 127.0.0.53). Leave unset to bind all interfaces (the default).
#
# Pass via env or set in secondary/.env so the Makefile auto-exports.
BIND_V4="${BIND_V4:-}"
BIND_V6="${BIND_V6:-}"
if [[ ! -d "$ZONES_DIR" ]]; then
echo "ERROR: zones dir $ZONES_DIR not found" >&2
exit 1
fi
# Collect zone names — strip the .zone suffix, sort for deterministic output.
mapfile -t zones < <(find "$ZONES_DIR" -maxdepth 1 -name '*.zone' -printf '%f\n' \
| sed 's/\.zone$//' | sort)
if [[ ${#zones[@]} -eq 0 ]]; then
echo "ERROR: no zones found in $ZONES_DIR" >&2
exit 1
fi
# Build the space-separated zone list. CoreDNS accepts an arbitrary number
# of zones in a single block header; with 84 zones this is one long line
# but the parser handles it cleanly and a single block is more efficient
# than 84 individual blocks (one plugin chain instead of 84).
#
# Each zone gets a trailing dot to remove ambiguity from the parser
# (otherwise CoreDNS appends the default origin, which we don't want).
zone_list=""
for z in "${zones[@]}"; do
zone_list+="${z}. "
done
zone_list="${zone_list% }"
{
echo "# AUTO-GENERATED by secondary/scripts/generate-secondary-corefile.sh"
echo "# Source: ${ZONES_DIR}/*.zone (${#zones[@]} zones)"
echo "# Re-generated: $(date -Iseconds)"
echo "# DO NOT EDIT BY HAND — re-run the generator instead."
echo ""
echo "# Public secondary for ${#zones[@]} zones. Pulls AXFR/IXFR from"
echo "# ${PRIMARY_IP} (dell01 hidden primary) and serves the public face."
echo "# Inbound NOTIFY from the same IP triggers immediate re-poll."
echo "${zone_list} {"
if [[ -n "$BIND_V4" || -n "$BIND_V6" ]]; then
# Bind only to specified addresses. Required when systemd-resolved
# or similar already owns loopback :53 — we share the port number
# across different IPs but the kernel needs explicit non-overlapping
# binds to allow it.
echo " bind ${BIND_V4} ${BIND_V6}"
fi
echo " secondary {"
echo " transfer from ${PRIMARY_IP}"
echo " }"
echo " log"
echo " errors"
echo " # No \`cache\` plugin — authoritative answers don't need it"
echo " # and caching authoritative responses muddies TTL semantics."
echo "}"
echo ""
echo "# Catch-all block: anything outside the authoritative zone list"
echo "# returns REFUSED. We're not a recursive resolver — public clients"
echo "# asking us to recurse get an explicit no."
echo ". {"
if [[ -n "$BIND_V4" || -n "$BIND_V6" ]]; then
echo " bind ${BIND_V4} ${BIND_V6}"
fi
echo " errors"
echo " log"
echo " # No plugins that answer — empty chain → REFUSED."
echo " # (The \`errors\` + \`log\` plugins record the attempt for visibility.)"
echo "}"
} > "$COREFILE"
bind_summary="all interfaces"
if [[ -n "$BIND_V4" || -n "$BIND_V6" ]]; then
bind_summary="${BIND_V4:-(none)} ${BIND_V6:-(none)}"
fi
echo "Wrote $COREFILE (${#zones[@]} zones, primary=${PRIMARY_IP}, bind=${bind_summary})"