coredns/secondary/scripts/generate-secondary-corefile.sh
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

110 lines
4.4 KiB
Bash
Executable File

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