coredns-rfc2136/zonefile_test.go

415 lines
14 KiB
Go
Raw Normal View History

Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
package rfc2136
import (
H3/H4/H5: zone-integrity invariants H3+H4 — Zone SOA invariant. After parsing, loadRRs enforces: exactly one SOA, owned by the zone apex. Catches three failure modes with a single guard: - Missing SOA (H4): a malformed line earlier in the file may have tripped miekg/dns's ZoneParser into dropping records without reporting an error via parser.Err(). If the SOA went missing, we refuse rather than treat the partial parse as authoritative. - Multiple SOAs (H3): zone files with accidental duplicate SOA records produce inconsistent zone state visible to AXFR clients. The old code's first-match SOA-bump would silently propagate the inconsistency. Now we refuse. - Non-apex SOA (H3): an SOA whose owner doesn't match the zone origin is either a parse error or a hand-edit mistake; bumping it would leave the real apex unchanged. Now we refuse. assertSingleApexSOA returns a descriptive error so the failure mode is actionable from logs alone. H5 — MaxUint32 guard in bumpSerial. The old "+1 defensive advance" branch would wrap to 0 if soa.Serial == MaxUint32, and downstream secondaries per RFC 1982 §3.2 treat 0-after-MaxUint32 as "older" (they refuse to AXFR and the zone goes dark). Now we explicitly check and refuse with a loud message; operator must reset the serial manually. Practical reach is zero for our deployment (10000 bumps/day × 117 years would still fit uint32) but the defensive ceiling matters for fuzz, hand-edit, or future code-path errors. The full RFC 1982 wraparound-aware comparison was prototyped but removed: it broke the legacy-format migration case where a tiny non-CalVer serial (e.g., 12345) is "more than 2^31 distant" from a new-format serial (~2.6B), which RFC 1982 reads as "going backwards" and would block migration. Naive `>` is correct in practice; the MaxUint32 case is the only real failure mode worth guarding. New tests: - TestBumpSerial_MaxUint32_RefusesWrap - TestLoadRRs_NoSOA_Refused - TestLoadRRs_MultipleSOAs_Refused - TestLoadRRs_NonApexSOA_Refused
2026-05-22 21:25:35 -06:00
"math"
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// mustRR is a test helper that parses an RR string or fails the test.
// Used widely in zonefile + update tests.
func mustRR(t *testing.T, s string) dns.RR {
t.Helper()
rr, err := dns.NewRR(s)
if err != nil {
t.Fatalf("failed to parse RR %q: %v", s, err)
}
return rr
}
func TestZoneFile_LoadRRs(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com")
zf := openZoneFile(filepath.Join(dir, "auth.example.com.zone"), "auth.example.com.")
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
rrs, _, err := zf.loadRRs()
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
if err != nil {
t.Fatalf("loadRRs: %v", err)
}
if len(rrs) < 2 {
t.Fatalf("expected >=2 RRs (SOA + NS), got %d", len(rrs))
}
// First RR should be the SOA.
if _, ok := rrs[0].(*dns.SOA); !ok {
t.Errorf("first RR is %T, want SOA", rrs[0])
}
}
func TestZoneFile_LoadRRs_MissingFile(t *testing.T) {
zf := openZoneFile("/nope/missing.zone", "missing.")
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
if _, _, err := zf.loadRRs(); err == nil {
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
t.Errorf("expected error loading missing file, got nil")
}
}
func TestLookupIn_CaseInsensitive(t *testing.T) {
rrs := []dns.RR{
mustRR(t, `FOO.example.com. 60 IN TXT "hello"`),
}
if got := lookupIn(rrs, "foo.EXAMPLE.com.", dns.TypeTXT); len(got) != 1 {
t.Errorf("case-insensitive lookup failed: %d results", len(got))
}
}
func TestLookupIn_WrongTypeReturnsEmpty(t *testing.T) {
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
if got := lookupIn(rrs, "foo.example.com.", dns.TypeTXT); len(got) != 0 {
t.Errorf("wrong-type lookup returned %d results, want 0", len(got))
}
}
func TestNameExistsIn(t *testing.T) {
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
if !nameExistsIn(rrs, "foo.example.com.") {
t.Errorf("nameExistsIn returned false for present name")
}
if nameExistsIn(rrs, "bar.example.com.") {
t.Errorf("nameExistsIn returned true for absent name")
}
}
func TestRemoveRRsetFrom(t *testing.T) {
rrs := []dns.RR{
mustRR(t, `foo.example.com. 60 IN TXT "a"`),
mustRR(t, `foo.example.com. 60 IN TXT "b"`),
mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`),
mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`),
}
out := removeRRsetFrom(rrs, "foo.example.com.", dns.TypeTXT)
if len(out) != 2 {
t.Errorf("after removing TXT rrset, got %d records, want 2", len(out))
}
// The remaining records: foo's A + bar's A
for _, rr := range out {
if rr.Header().Rrtype == dns.TypeTXT {
t.Errorf("a TXT slipped through removal: %s", rr.String())
}
}
}
func TestRemoveRRFrom_ExactMatchOnly(t *testing.T) {
rrs := []dns.RR{
mustRR(t, `foo.example.com. 60 IN TXT "keep"`),
mustRR(t, `foo.example.com. 60 IN TXT "drop"`),
}
out := removeRRFrom(rrs, mustRR(t, `foo.example.com. 60 IN TXT "drop"`))
if len(out) != 1 || out[0].(*dns.TXT).Txt[0] != "keep" {
t.Errorf("removeRRFrom wrong result: %v", out)
}
}
func TestRemoveNameFrom(t *testing.T) {
rrs := []dns.RR{
mustRR(t, `foo.example.com. 60 IN TXT "x"`),
mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`),
mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`),
}
out := removeNameFrom(rrs, "foo.example.com.")
if len(out) != 1 || out[0].Header().Name != "bar.example.com." {
t.Errorf("removeNameFrom left %v, want only bar", out)
}
}
func TestAddRRTo_Dedupes(t *testing.T) {
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN TXT "same"`)}
out := addRRTo(rrs, mustRR(t, `foo.example.com. 60 IN TXT "same"`))
if len(out) != 1 {
t.Errorf("identical RR was duplicated: %v", out)
}
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
// bumpSerial tests below pin the YYMMDD*10000+NNNN encoding. For
// 2026-05-21 (used as `now` in most cases): YYMMDD=260521, baseline
// "today, NNNN=0001" = 2,605,210,001.
func TestBumpSerial_SameDay_NNNNIncrement(t *testing.T) {
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
Serial: 2605210105}, // today, NNNN=0105
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
if got := rrs[0].(*dns.SOA).Serial; got != 2605210106 {
t.Errorf("Serial = %d, want 2605210106", got)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
func TestBumpSerial_NewDay_DateAdvancesNNNN0001(t *testing.T) {
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
Serial: 2605209999}, // exhausted yesterday (in new format)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 {
t.Errorf("Serial = %d, want 2605210001 (today, NNNN=0001)", got)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
func TestBumpSerial_NNNN9999_RollsToNextEncodedDay(t *testing.T) {
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
Serial: 2605219999}, // today, NNNN=9999 (the burst-day overflow case)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
if got := rrs[0].(*dns.SOA).Serial; got != 2605220001 {
t.Errorf("Serial = %d, want 2605220001 (next encoded day, NNNN=0001)", got)
}
}
func TestBumpSerial_FutureEncodedDate_DoesNotRegress(t *testing.T) {
// Reproduces the bug from 2026-05-22: an earlier bumpSerial would
// downgrade a future-encoded serial back to today's NNNN=0001. Now,
// future-encoded serials are honoured: their NNNN increments.
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: 2605220500}, // tomorrow (encoded), NNNN=0500
}
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
if got := rrs[0].(*dns.SOA).Serial; got != 2605220501 {
t.Errorf("Serial = %d, want 2605220501 (future date NNNN+1, no regression)", got)
}
}
func TestBumpSerial_LegacyYYYYMMDDNNFormat_MigratesForward(t *testing.T) {
// The migration path: any production zone whose SOA serial is in the
// old 10-digit YYYYMMDDNN format (e.g., 2026052299) gets numerically
// crushed by today's YYMMDDNNNN minimum, so the "older/unparseable"
// branch fires and rewrites in place.
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: 2026052299}, // legacy YYYYMMDDNN, exhausted day
}
now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) // 2026-05-22
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
got := rrs[0].(*dns.SOA).Serial
if got != 2605220001 {
t.Errorf("Serial = %d, want 2605220001 (migrated to new format)", got)
}
if got <= 2026052299 {
t.Errorf("migration regressed serial: %d <= legacy %d", got, 2026052299)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
}
func TestBumpSerial_NonCalVerFormat_ResetsToToday(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
Serial: 12345}, // random small value, not CalVer
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err != nil {
t.Fatalf("bumpSerial: %v", err)
}
Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
2026-05-22 11:51:45 -06:00
if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 {
t.Errorf("Serial = %d, want 2605210001", got)
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
}
}
H3/H4/H5: zone-integrity invariants H3+H4 — Zone SOA invariant. After parsing, loadRRs enforces: exactly one SOA, owned by the zone apex. Catches three failure modes with a single guard: - Missing SOA (H4): a malformed line earlier in the file may have tripped miekg/dns's ZoneParser into dropping records without reporting an error via parser.Err(). If the SOA went missing, we refuse rather than treat the partial parse as authoritative. - Multiple SOAs (H3): zone files with accidental duplicate SOA records produce inconsistent zone state visible to AXFR clients. The old code's first-match SOA-bump would silently propagate the inconsistency. Now we refuse. - Non-apex SOA (H3): an SOA whose owner doesn't match the zone origin is either a parse error or a hand-edit mistake; bumping it would leave the real apex unchanged. Now we refuse. assertSingleApexSOA returns a descriptive error so the failure mode is actionable from logs alone. H5 — MaxUint32 guard in bumpSerial. The old "+1 defensive advance" branch would wrap to 0 if soa.Serial == MaxUint32, and downstream secondaries per RFC 1982 §3.2 treat 0-after-MaxUint32 as "older" (they refuse to AXFR and the zone goes dark). Now we explicitly check and refuse with a loud message; operator must reset the serial manually. Practical reach is zero for our deployment (10000 bumps/day × 117 years would still fit uint32) but the defensive ceiling matters for fuzz, hand-edit, or future code-path errors. The full RFC 1982 wraparound-aware comparison was prototyped but removed: it broke the legacy-format migration case where a tiny non-CalVer serial (e.g., 12345) is "more than 2^31 distant" from a new-format serial (~2.6B), which RFC 1982 reads as "going backwards" and would block migration. Naive `>` is correct in practice; the MaxUint32 case is the only real failure mode worth guarding. New tests: - TestBumpSerial_MaxUint32_RefusesWrap - TestLoadRRs_NoSOA_Refused - TestLoadRRs_MultipleSOAs_Refused - TestLoadRRs_NonApexSOA_Refused
2026-05-22 21:25:35 -06:00
// TestBumpSerial_MaxUint32_RefusesWrap covers H5: the defensive
// branch must not wrap soa.Serial to 0. Wrap-to-0 makes downstream
// secondaries treat the zone as reset per RFC 1982 and refuse to AXFR
// the new value, taking the zone dark.
func TestBumpSerial_MaxUint32_RefusesWrap(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: math.MaxUint32},
}
now := time.Date(2026, 5, 22, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err == nil {
t.Errorf("bumpSerial accepted MaxUint32; expected refusal to prevent wrap-to-0")
}
if got := rrs[0].(*dns.SOA).Serial; got == 0 {
t.Errorf("Serial wrapped to 0 (catastrophic): %d", got)
}
}
// TestLoadRRs_NoSOA_Refused covers H4: a zone file that's missing its
// SOA (because the parser ate it on a malformed prior line, or because
// it was never there) must fail loadRRs rather than be silently treated
// as a valid empty-of-SOA zone.
func TestLoadRRs_NoSOA_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "noSoA.example.com.zone")
content := "$ORIGIN noSoA.example.com.\nfoo.noSoA.example.com. 60 IN A 192.0.2.1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "noSoA.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted SOA-less zone file; expected refusal")
}
}
// TestLoadRRs_MultipleSOAs_Refused covers H3: two SOAs at the apex
// produce inconsistent zone state visible to AXFR clients. We refuse
// to operate on such a file rather than bumping only the first SOA.
func TestLoadRRs_MultipleSOAs_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dupe.example.com.zone")
content := `$ORIGIN dupe.example.com.
dupe.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60
dupe.example.com. 3600 IN SOA ns.example. admin.example. 2 60 60 60 60
dupe.example.com. 3600 IN NS ns.example.
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "dupe.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted dual-SOA zone; expected refusal")
}
}
// TestLoadRRs_NonApexSOA_Refused covers H3: an SOA owned by a name
// other than the zone apex is a parse-error red flag and must be
// refused, not silently bumped.
func TestLoadRRs_NonApexSOA_Refused(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "weird.example.com.zone")
content := `$ORIGIN weird.example.com.
sub.weird.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60
weird.example.com. 3600 IN NS ns.example.
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write fixture: %v", err)
}
zf := openZoneFile(path, "weird.example.com.")
if _, _, err := zf.loadRRs(); err == nil {
t.Errorf("loadRRs accepted non-apex SOA; expected refusal")
}
}
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
func TestBumpSerial_NoSOA_ReturnsError(t *testing.T) {
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
now := time.Now()
if err := bumpSerial(rrs, now); err == nil {
t.Errorf("expected error when no SOA in zone, got nil")
}
}
func TestZoneFile_WriteAtomic_RoundTrip(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com")
path := filepath.Join(dir, "auth.example.com.zone")
zf := openZoneFile(path, "auth.example.com.")
zf.AutoCommit = false // not testing commit here
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
rrs, _, err := zf.loadRRs()
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
if err != nil {
t.Fatalf("initial load: %v", err)
}
originalCount := len(rrs)
// Add a record + write.
rrs = addRRTo(rrs, mustRR(t, `new.auth.example.com. 60 IN TXT "added"`))
if err := zf.writeAtomic(rrs, time.Now()); err != nil {
t.Fatalf("writeAtomic: %v", err)
}
// Re-load and verify.
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
after, _, err := zf.loadRRs()
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
if err != nil {
t.Fatalf("re-load: %v", err)
}
if len(after) != originalCount+1 {
t.Errorf("RR count = %d, want %d", len(after), originalCount+1)
}
found := false
for _, rr := range after {
if txt, ok := rr.(*dns.TXT); ok && txt.Hdr.Name == "new.auth.example.com." {
found = true
break
}
}
if !found {
t.Errorf("added TXT not in reloaded zone")
}
}
func TestZoneFile_WriteAtomic_LeavesNoTempFile(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com")
path := filepath.Join(dir, "auth.example.com.zone")
zf := openZoneFile(path, "auth.example.com.")
zf.AutoCommit = false
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
rrs, _, _ := zf.loadRRs()
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
_ = zf.writeAtomic(rrs, time.Now())
// No .rfc2136-*.zone temp files should remain.
matches, _ := filepath.Glob(filepath.Join(dir, ".rfc2136-*.zone"))
if len(matches) != 0 {
t.Errorf("temp files leaked: %v", matches)
}
}
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
// TestZoneFile_CheckUnchanged_DetectsExternalModification covers H1:
// between loadRRs and the next writeAtomic, if anything (rsync, manual
// edit, git checkout) clobbered the file, checkUnchanged returns an
// error so the caller refuses the UPDATE instead of losing the
// external write.
func TestZoneFile_CheckUnchanged_DetectsExternalModification(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com")
path := filepath.Join(dir, "auth.example.com.zone")
zf := openZoneFile(path, "auth.example.com.")
zf.AutoCommit = false
_, snap, err := zf.loadRRs()
if err != nil {
t.Fatalf("loadRRs: %v", err)
}
// Pristine snapshot — checkUnchanged should pass.
if err := zf.checkUnchanged(snap); err != nil {
t.Errorf("pristine snapshot rejected: %v", err)
}
// Simulate an external editor clobbering the file (rsync-style:
// new content, different size, mtime advances).
time.Sleep(10 * time.Millisecond) // ensure mtime differs
if err := os.WriteFile(path, []byte("; external edit\n$ORIGIN auth.example.com.\nauth.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60\n"), 0644); err != nil {
t.Fatalf("simulated external edit: %v", err)
}
if err := zf.checkUnchanged(snap); err == nil {
t.Errorf("checkUnchanged accepted modified file; expected concurrent-modification error")
}
}
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
func TestZoneFile_WriteAtomic_FileEndsWithNewline(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com")
path := filepath.Join(dir, "auth.example.com.zone")
zf := openZoneFile(path, "auth.example.com.")
zf.AutoCommit = false
H1/H2/M1: atomicity at the file boundary H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
2026-05-22 21:22:11 -06:00
rrs, _, _ := zf.loadRRs()
Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00
_ = zf.writeAtomic(rrs, time.Now())
data, _ := os.ReadFile(path)
if !strings.HasSuffix(string(data), "\n") {
t.Errorf("written file should end with newline; tail: %q",
data[max(0, len(data)-20):])
}
}
func max(a, b int) int {
if a > b {
return a
}
return b
}