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).
298 lines
8.3 KiB
Go
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
|
|
}
|