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 }