ServeDNS now answers authoritatively for the configured zone(s): - Apex SOA → synthetic SOA (serial = store generation counter) - Apex NS → synthetic NS pointing at p.Nameserver - In-store lookups for any qtype - NODATA vs NXDOMAIN correctly distinguished (SOA in authority section) - UPDATE opcode → REFUSED (Phase 1.4 implements properly) - Queries outside our zones pass through to Next Added: - store.go: recordStore with sync.RWMutex + atomic generation counter. Operations: Add (de-dupes), RemoveRRset, RemoveRR, RemoveName, Lookup (returns a copy so callers can't corrupt internal state), NameExists. All keyed on canonical lowercase + trailing-dot names. - plugin.go: ServeDNS dispatch, findZone (longest-suffix match), syntheticSOA, syntheticNS. New Nameserver field. - setup.go: nameserver directive. Default Nameserver = first zone apex. Store initialised at parse time. - store_test.go: 12 unit tests covering add/dedupe/remove/lookup/ generation/case-insensitivity/copy-safety. - plugin_test.go: 10 dispatch tests covering pass-through, apex synthetics, in-store lookups, NXDOMAIN/NODATA semantics, UPDATE refusal, findZone longest-suffix-wins and case behavior. - setup_test.go: 3 new cases for the nameserver directive + store init. Total: 38 tests passing. Module: git.supported.systems/rsp2k/coredns-rfc2136
239 lines
6.7 KiB
Go
239 lines
6.7 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"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="
|
|
|
|
// TestParse exercises the Corefile parser across the matrix of valid
|
|
// and invalid configurations. Each row is fully self-contained.
|
|
func TestParse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
shouldErr bool
|
|
errMatch string // substring to look for in the error (when shouldErr)
|
|
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",
|
|
input: `rfc2136 auth.example.com. {
|
|
tsig-key acme-key. hmac-sha256 ` + testSecret + `
|
|
ttl 120
|
|
persist /var/lib/coredns/rfc2136/auth.db
|
|
}`,
|
|
check: func(t *testing.T, p *RFC2136) {
|
|
wantZone(t, p, "auth.example.com.")
|
|
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)
|
|
}
|
|
k, ok := p.TSIGKeys["acme-key."]
|
|
if !ok {
|
|
t.Fatalf("acme-key. not in TSIGKeys; have keys=%v", keysOf(p.TSIGKeys))
|
|
}
|
|
if k.Algorithm != dns.HmacSHA256 {
|
|
t.Errorf("Algorithm = %q, want %q", k.Algorithm, dns.HmacSHA256)
|
|
}
|
|
if len(k.Secret) != 32 {
|
|
t.Errorf("Secret length = %d, want 32 (decoded SHA-256-sized)", len(k.Secret))
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "tsig-key name normalised to trailing-dot lowercase",
|
|
input: `rfc2136 auth.example.com. {
|
|
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))
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "multiple tsig-keys allowed",
|
|
input: `rfc2136 auth.example.com. {
|
|
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")
|
|
}
|
|
},
|
|
},
|
|
|
|
// ─── error cases ──────────────────────────────────────────
|
|
{
|
|
name: "no zone",
|
|
input: `rfc2136`,
|
|
shouldErr: true,
|
|
errMatch: "Wrong argument count",
|
|
},
|
|
{
|
|
name: "unknown directive",
|
|
input: `rfc2136 auth.example.com. { bogus value }`,
|
|
shouldErr: true,
|
|
errMatch: "unknown directive",
|
|
},
|
|
{
|
|
name: "tsig-key with too few args",
|
|
input: `rfc2136 auth.example.com. {
|
|
tsig-key only-name
|
|
}`,
|
|
shouldErr: true,
|
|
errMatch: "tsig-key requires 3 args",
|
|
},
|
|
{
|
|
name: "unsupported TSIG algorithm",
|
|
input: `rfc2136 auth.example.com. {
|
|
tsig-key key. hmac-md5 ` + testSecret + `
|
|
}`,
|
|
shouldErr: true,
|
|
errMatch: "unsupported TSIG algorithm",
|
|
},
|
|
{
|
|
name: "malformed base64 secret",
|
|
input: `rfc2136 auth.example.com. {
|
|
tsig-key key. hmac-sha256 not_base64_at_all!!!
|
|
}`,
|
|
shouldErr: true,
|
|
errMatch: "invalid base64",
|
|
},
|
|
{
|
|
name: "secret too short after decode",
|
|
input: `rfc2136 auth.example.com. {
|
|
tsig-key key. hmac-sha256 c2hvcnQ=
|
|
}`, // "short" → 5 bytes < 8 min
|
|
shouldErr: true,
|
|
errMatch: "too short",
|
|
},
|
|
{
|
|
name: "duplicate tsig-key name",
|
|
input: `rfc2136 auth.example.com. {
|
|
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. {
|
|
ttl not-a-number
|
|
}`,
|
|
shouldErr: true,
|
|
errMatch: "ttl must be a non-negative integer",
|
|
},
|
|
}
|
|
|
|
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) != tt.shouldErr {
|
|
t.Fatalf("parse() 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if z == want {
|
|
return
|
|
}
|
|
}
|
|
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 {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|