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).
This commit is contained in:
parent
1d2d919728
commit
0f28127284
10 changed files with 1223 additions and 1109 deletions
231
setup_test.go
231
setup_test.go
|
|
@ -1,6 +1,8 @@
|
|||
package rfc2136
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -13,93 +15,126 @@ import (
|
|||
// value in production — it's literally in this file in plaintext.
|
||||
const testSecret = "xTgset4zj7kHqniSslYFn+OcdCf419olek9MNmOvlUM="
|
||||
|
||||
// TestParse exercises the Corefile parser across the matrix of valid
|
||||
// and invalid configurations. Each row is fully self-contained.
|
||||
// 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 // substring to look for in the error (when shouldErr)
|
||||
errMatch string
|
||||
check func(t *testing.T, p *RFC2136)
|
||||
}{
|
||||
{
|
||||
name: "single zone, no block",
|
||||
input: `rfc2136 auth.example.com.`,
|
||||
check: func(t *testing.T, p *RFC2136) {
|
||||
wantZone(t, p, "auth.example.com.")
|
||||
if p.TTL != DefaultTTL {
|
||||
t.Errorf("TTL = %d, want default %d", p.TTL, DefaultTTL)
|
||||
}
|
||||
if len(p.TSIGKeys) != 0 {
|
||||
t.Errorf("expected 0 TSIG keys, got %d", len(p.TSIGKeys))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple zones in one directive",
|
||||
input: `rfc2136 auth.example.com. acme.example.org.`,
|
||||
check: func(t *testing.T, p *RFC2136) {
|
||||
if len(p.Zones) != 2 {
|
||||
t.Fatalf("Zones = %v, want 2", p.Zones)
|
||||
}
|
||||
wantZone(t, p, "auth.example.com.")
|
||||
wantZone(t, p, "acme.example.org.")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full block with all directives",
|
||||
name: "minimal: zone + zones-dir",
|
||||
input: `rfc2136 auth.example.com. {
|
||||
tsig-key acme-key. hmac-sha256 ` + testSecret + `
|
||||
ttl 120
|
||||
persist /var/lib/coredns/rfc2136/auth.db
|
||||
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.PersistPath != "/var/lib/coredns/rfc2136/auth.db" {
|
||||
t.Errorf("PersistPath = %q", p.PersistPath)
|
||||
if p.AutoCommit {
|
||||
t.Errorf("AutoCommit should be false")
|
||||
}
|
||||
k, ok := p.TSIGKeys["acme-key."]
|
||||
if !ok {
|
||||
t.Fatalf("acme-key. not in TSIGKeys; have keys=%v", keysOf(p.TSIGKeys))
|
||||
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))
|
||||
}
|
||||
if k.Algorithm != dns.HmacSHA256 {
|
||||
t.Errorf("Algorithm = %q, want %q", k.Algorithm, dns.HmacSHA256)
|
||||
// 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 len(k.Secret) != 32 {
|
||||
t.Errorf("Secret length = %d, want 32 (decoded SHA-256-sized)", len(k.Secret))
|
||||
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 key 'acme-key.', got keys=%v", keysOf(p.TSIGKeys))
|
||||
t.Errorf("expected canonicalised 'acme-key.', keys=%v", keysOf(p.TSIGKeys))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tsig-keys allowed",
|
||||
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("expected 2 keys, got %d (%v)", len(p.TSIGKeys), keysOf(p.TSIGKeys))
|
||||
}
|
||||
if p.TSIGKeys["key-a."].Algorithm != dns.HmacSHA256 {
|
||||
t.Errorf("key-a algorithm wrong")
|
||||
}
|
||||
if p.TSIGKeys["key-b."].Algorithm != dns.HmacSHA512 {
|
||||
t.Errorf("key-b algorithm wrong")
|
||||
t.Errorf("want 2 keys, got %d", len(p.TSIGKeys))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -112,14 +147,40 @@ func TestParse(t *testing.T) {
|
|||
errMatch: "Wrong argument count",
|
||||
},
|
||||
{
|
||||
name: "unknown directive",
|
||||
input: `rfc2136 auth.example.com. { bogus value }`,
|
||||
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,
|
||||
|
|
@ -128,6 +189,7 @@ func TestParse(t *testing.T) {
|
|||
{
|
||||
name: "unsupported TSIG algorithm",
|
||||
input: `rfc2136 auth.example.com. {
|
||||
zones-dir ` + dir + `
|
||||
tsig-key key. hmac-md5 ` + testSecret + `
|
||||
}`,
|
||||
shouldErr: true,
|
||||
|
|
@ -136,73 +198,72 @@ func TestParse(t *testing.T) {
|
|||
{
|
||||
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 after decode",
|
||||
name: "secret too short",
|
||||
input: `rfc2136 auth.example.com. {
|
||||
zones-dir ` + dir + `
|
||||
tsig-key key. hmac-sha256 c2hvcnQ=
|
||||
}`, // "short" → 5 bytes < 8 min
|
||||
}`,
|
||||
shouldErr: true,
|
||||
errMatch: "too short",
|
||||
},
|
||||
{
|
||||
name: "duplicate tsig-key name",
|
||||
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: "nameserver directive overrides default",
|
||||
input: `rfc2136 auth.example.com. {
|
||||
nameserver dns.example.com.
|
||||
}`,
|
||||
check: func(t *testing.T, p *RFC2136) {
|
||||
if p.Nameserver != "dns.example.com." {
|
||||
t.Errorf("Nameserver = %q, want dns.example.com.", p.Nameserver)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default nameserver is first zone apex",
|
||||
input: `rfc2136 auth.example.com.`,
|
||||
check: func(t *testing.T, p *RFC2136) {
|
||||
if p.Nameserver != "auth.example.com." {
|
||||
t.Errorf("default Nameserver = %q, want auth.example.com.", p.Nameserver)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "store is initialised even with no records",
|
||||
input: `rfc2136 auth.example.com.`,
|
||||
check: func(t *testing.T, p *RFC2136) {
|
||||
if p.store == nil {
|
||||
t.Errorf("store should be initialised by parse()")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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("parse() err = %v, shouldErr = %v", err, tt.shouldErr)
|
||||
t.Fatalf("err = %v, shouldErr = %v", err, tt.shouldErr)
|
||||
}
|
||||
if err != nil {
|
||||
if tt.errMatch != "" && !strings.Contains(err.Error(), tt.errMatch) {
|
||||
|
|
@ -217,7 +278,6 @@ func TestParse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// wantZone asserts that p.Zones contains the expected canonical zone.
|
||||
func wantZone(t *testing.T, p *RFC2136, want string) {
|
||||
t.Helper()
|
||||
for _, z := range p.Zones {
|
||||
|
|
@ -228,7 +288,6 @@ func wantZone(t *testing.T, p *RFC2136, want string) {
|
|||
t.Errorf("zone %q not in p.Zones=%v", want, p.Zones)
|
||||
}
|
||||
|
||||
// keysOf is a debug helper for failure messages.
|
||||
func keysOf(m map[string]tsigKey) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue