coredns-rfc2136/setup_test.go
Ryan Malloy 0f28127284 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

298 lines
8.3 KiB
Go

package rfc2136
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/coredns/caddy"
"github.com/miekg/dns"
)
// A 32-byte HMAC-SHA256 secret, base64-encoded. Generated once and
// re-used across tests so failures are reproducible. NEVER use this
// value in production — it's literally in this file in plaintext.
const testSecret = "xTgset4zj7kHqniSslYFn+OcdCf419olek9MNmOvlUM="
// withTempZonesDir creates a zones-dir with the named .zone files
// (each containing a minimal valid zone) and returns the directory
// path plus a cleanup func. The minimal zone has an SOA + NS, which
// satisfies validateZoneFiles().
func withTempZonesDir(t *testing.T, zones ...string) string {
t.Helper()
dir := t.TempDir()
for _, z := range zones {
path := filepath.Join(dir, strings.TrimSuffix(z, ".")+".zone")
content := minimalZone(z)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
return dir
}
func minimalZone(zone string) string {
z := strings.TrimSuffix(zone, ".")
return "$ORIGIN " + z + ".\n" +
"$TTL 3600\n" +
"@ 3600 IN SOA ns." + z + ". admin." + z + ". 2026052101 300 120 604800 60\n" +
"@ 3600 IN NS ns." + z + ".\n"
}
func TestParse(t *testing.T) {
dir := withTempZonesDir(t, "auth.example.com", "acme.example.org")
tests := []struct {
name string
input string
shouldErr bool
errMatch string
check func(t *testing.T, p *RFC2136)
}{
{
name: "minimal: zone + zones-dir",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
}`,
check: func(t *testing.T, p *RFC2136) {
wantZone(t, p, "auth.example.com.")
if p.ZonesDir != dir {
t.Errorf("ZonesDir = %q, want %q", p.ZonesDir, dir)
}
if p.TTL != DefaultTTL {
t.Errorf("TTL = %d, want default %d", p.TTL, DefaultTTL)
}
if !p.AutoCommit {
t.Errorf("AutoCommit should default to true")
}
if len(p.zones) != 1 {
t.Errorf("expected 1 zoneFile, got %d", len(p.zones))
}
},
},
{
name: "multiple zones",
input: `rfc2136 auth.example.com. acme.example.org. {
zones-dir ` + dir + `
}`,
check: func(t *testing.T, p *RFC2136) {
if len(p.Zones) != 2 || len(p.zones) != 2 {
t.Errorf("Zones=%v zoneFiles=%d", p.Zones, len(p.zones))
}
},
},
{
name: "full block: tsig-key, ttl, auto-commit, git-author",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key acme-key. hmac-sha256 ` + testSecret + `
ttl 120
auto-commit false
git-author "RFC 2136" "rfc2136@example.com"
}`,
check: func(t *testing.T, p *RFC2136) {
if p.TTL != 120 {
t.Errorf("TTL = %d, want 120", p.TTL)
}
if p.AutoCommit {
t.Errorf("AutoCommit should be false")
}
if k, ok := p.TSIGKeys["acme-key."]; !ok {
t.Errorf("acme-key. not in TSIGKeys")
} else if k.Algorithm != dns.HmacSHA256 || len(k.Secret) != 32 {
t.Errorf("TSIG key wrong: algo=%q len=%d", k.Algorithm, len(k.Secret))
}
// git-author should propagate to each zoneFile
zf := p.zones["auth.example.com."]
if zf.GitAuthorName != "RFC 2136" || zf.GitAuthorEmail != "rfc2136@example.com" {
t.Errorf("git author not propagated: %q %q", zf.GitAuthorName, zf.GitAuthorEmail)
}
if zf.AutoCommit {
t.Errorf("zoneFile.AutoCommit should match p.AutoCommit (false)")
}
},
},
{
name: "tsig-key name normalised to trailing-dot lowercase",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key Acme-Key hmac-sha256 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if _, ok := p.TSIGKeys["acme-key."]; !ok {
t.Errorf("expected canonicalised 'acme-key.', keys=%v", keysOf(p.TSIGKeys))
}
},
},
{
name: "multiple tsig-keys for rotation",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key key-a. hmac-sha256 ` + testSecret + `
tsig-key key-b. hmac-sha512 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if len(p.TSIGKeys) != 2 {
t.Errorf("want 2 keys, got %d", len(p.TSIGKeys))
}
},
},
// ─── error cases ──────────────────────────────────────────
{
name: "no zone",
input: `rfc2136`,
shouldErr: true,
errMatch: "Wrong argument count",
},
{
name: "missing zones-dir",
input: `rfc2136 auth.example.com.`,
shouldErr: true,
errMatch: "zones-dir is required",
},
{
name: "zones-dir points at non-existent dir",
input: `rfc2136 auth.example.com. {
zones-dir /definitely/not/a/real/path
}`,
shouldErr: true,
errMatch: "file not accessible",
},
{
name: "zone declared but no matching file",
input: `rfc2136 missing.example.com. {
zones-dir ` + dir + `
}`,
shouldErr: true,
errMatch: "file not accessible",
},
{
name: "unknown directive",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
bogus value
}`,
shouldErr: true,
errMatch: "unknown directive",
},
{
name: "tsig-key with too few args",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key only-name
}`,
shouldErr: true,
errMatch: "tsig-key requires 3 args",
},
{
name: "unsupported TSIG algorithm",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key key. hmac-md5 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "unsupported TSIG algorithm",
},
{
name: "malformed base64 secret",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key key. hmac-sha256 not_base64_at_all!!!
}`,
shouldErr: true,
errMatch: "invalid base64",
},
{
name: "secret too short",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key key. hmac-sha256 c2hvcnQ=
}`,
shouldErr: true,
errMatch: "too short",
},
{
name: "duplicate tsig-key",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
tsig-key dup. hmac-sha256 ` + testSecret + `
tsig-key dup. hmac-sha512 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "duplicate tsig-key",
},
{
name: "ttl non-numeric",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
ttl not-a-number
}`,
shouldErr: true,
errMatch: "ttl must be a non-negative integer",
},
{
name: "auto-commit bogus value",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
auto-commit maybe
}`,
shouldErr: true,
errMatch: "auto-commit must be true|false",
},
{
name: "git-author wrong arg count",
input: `rfc2136 auth.example.com. {
zones-dir ` + dir + `
git-author OnlyName
}`,
shouldErr: true,
errMatch: "git-author requires 2 args",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := caddy.NewTestController("dns", tt.input)
p, err := parse(c)
if err == nil {
// Always run validateZoneFiles when parse succeeds —
// some error cases (missing zones-dir, missing file)
// trigger here, not in parse() itself.
err = p.validateZoneFiles()
}
if (err != nil) != tt.shouldErr {
t.Fatalf("err = %v, shouldErr = %v", err, tt.shouldErr)
}
if err != nil {
if tt.errMatch != "" && !strings.Contains(err.Error(), tt.errMatch) {
t.Errorf("err = %q, expected substring %q", err.Error(), tt.errMatch)
}
return
}
if tt.check != nil {
tt.check(t, p)
}
})
}
}
func wantZone(t *testing.T, p *RFC2136, want string) {
t.Helper()
for _, z := range p.Zones {
if z == want {
return
}
}
t.Errorf("zone %q not in p.Zones=%v", want, p.Zones)
}
func keysOf(m map[string]tsigKey) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}