Phase 1.3: in-memory store + ServeDNS query dispatch
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
This commit is contained in:
parent
eba6313ec0
commit
1cca9a5aa7
185
plugin.go
185
plugin.go
@ -4,26 +4,17 @@
|
||||
// client (e.g. Caddy via caddy-dns/rfc2136) injects _acme-challenge TXT
|
||||
// records into a delegated sub-zone that this plugin serves.
|
||||
//
|
||||
// Scope:
|
||||
// - Handles UPDATE messages (OPCODE=5) for configured zones.
|
||||
// - Verifies TSIG signatures (HMAC-SHA family; algorithm-pluggable).
|
||||
// - Stores records in memory; optional periodic snapshot to disk.
|
||||
// - Serves queries (SOA, NS, A, AAAA, TXT) for the configured zone
|
||||
// from the in-memory store plus a synthetic SOA/NS apex.
|
||||
//
|
||||
// Non-goals:
|
||||
// - General-purpose authoritative DNS (use `auto`/`file` for that).
|
||||
// - DNSSEC signing (add later via the `dnssec` plugin in front).
|
||||
//
|
||||
// Phase 1.2 status: parser fully wires Corefile into typed config.
|
||||
// ServeDNS still passes through to the next plugin — UPDATE handling
|
||||
// and zone-serving land in Phase 1.3/1.4. See plan at
|
||||
// Phase 1.3 status: store + query dispatch. ServeDNS now answers
|
||||
// authoritatively for the configured zone(s) from the in-memory store
|
||||
// (plus synthetic SOA/NS at apex). UPDATE handling still rejected —
|
||||
// that lands in Phase 1.4. See plan at
|
||||
//
|
||||
// ~/.claude/plans/dood-does-coredns-offer-enumerated-piglet.md
|
||||
package rfc2136
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/coredns/coredns/plugin"
|
||||
"github.com/miekg/dns"
|
||||
@ -46,8 +37,8 @@ type RFC2136 struct {
|
||||
Zones []string
|
||||
|
||||
// TSIGKeys is keyed by canonical key name (lowercased, trailing
|
||||
// dot). Empty means TSIG is disabled — UPDATEs without TSIG would
|
||||
// be rejected unconditionally in Phase 1.4.
|
||||
// dot). Empty means TSIG is disabled — UPDATEs without TSIG are
|
||||
// rejected unconditionally in Phase 1.4.
|
||||
TSIGKeys map[string]tsigKey
|
||||
|
||||
// TTL is applied to dynamically-injected records that don't carry
|
||||
@ -56,17 +47,167 @@ type RFC2136 struct {
|
||||
|
||||
// PersistPath, when non-empty, names a file the plugin writes a
|
||||
// JSON snapshot of its in-memory store to on a periodic schedule.
|
||||
// Empty means in-memory only (acceptable for ACME challenges,
|
||||
// which are seconds-to-minutes lived and re-issued on restart).
|
||||
// Empty means in-memory only (acceptable for ACME challenges).
|
||||
PersistPath string
|
||||
|
||||
// Nameserver is the host returned in synthetic NS records and as
|
||||
// the SOA's MNAME. Defaults (set in setup) to the first zone apex.
|
||||
Nameserver string
|
||||
|
||||
// store holds the dynamic records. Always non-nil after setup.
|
||||
store *recordStore
|
||||
}
|
||||
|
||||
// Name implements plugin.Handler.
|
||||
func (p *RFC2136) Name() string { return "rfc2136" }
|
||||
|
||||
// ServeDNS implements plugin.Handler. Phase 1.x is a pass-through so
|
||||
// the plugin can register, parse config, and live in the chain without
|
||||
// changing behavior. Phase 1.3 wires UPDATE handling + query serving.
|
||||
// ServeDNS implements plugin.Handler.
|
||||
//
|
||||
// Dispatch:
|
||||
//
|
||||
// UPDATE opcode → rejected with REFUSED (Phase 1.4 implements properly).
|
||||
// Query opcode:
|
||||
// - Not in our zones → pass to Next.
|
||||
// - Apex SOA → synthetic SOA.
|
||||
// - Apex NS → synthetic NS.
|
||||
// - Match in store → return RRset.
|
||||
// - Name exists, wrong type → NODATA (NOERROR + SOA in authority).
|
||||
// - Name doesn't exist → NXDOMAIN (NameError + SOA in authority).
|
||||
func (p *RFC2136) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||
return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r)
|
||||
if r.Opcode == dns.OpcodeUpdate {
|
||||
// Phase 1.4 will dispatch to update handler. For now, refuse
|
||||
// loudly so clients know the plugin is loaded but not yet
|
||||
// accepting updates.
|
||||
return p.refuseUpdate(w, r)
|
||||
}
|
||||
|
||||
if len(r.Question) == 0 {
|
||||
return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r)
|
||||
}
|
||||
|
||||
q := r.Question[0]
|
||||
zone := p.findZone(q.Name)
|
||||
if zone == "" {
|
||||
return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r)
|
||||
}
|
||||
|
||||
// We're authoritative for this name. Build a reply.
|
||||
msg := new(dns.Msg)
|
||||
msg.SetReply(r)
|
||||
msg.Authoritative = true
|
||||
|
||||
qname := strings.ToLower(dns.Fqdn(q.Name))
|
||||
isApex := qname == zone
|
||||
|
||||
// Apex SOA / NS are synthetic.
|
||||
if isApex {
|
||||
switch q.Qtype {
|
||||
case dns.TypeSOA:
|
||||
msg.Answer = []dns.RR{p.syntheticSOA(zone)}
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeSuccess, nil
|
||||
case dns.TypeNS:
|
||||
msg.Answer = p.syntheticNS(zone)
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeSuccess, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the asked type in the store.
|
||||
if rrs := p.store.Lookup(qname, q.Qtype); rrs != nil {
|
||||
msg.Answer = rrs
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeSuccess, nil
|
||||
}
|
||||
|
||||
// Special case: ANY at the apex isn't in the store but we have
|
||||
// synthetic SOA + NS. Return them rather than NODATA.
|
||||
if isApex && q.Qtype == dns.TypeANY {
|
||||
msg.Answer = append(msg.Answer, p.syntheticSOA(zone))
|
||||
msg.Answer = append(msg.Answer, p.syntheticNS(zone)...)
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeSuccess, nil
|
||||
}
|
||||
|
||||
// Distinguish NODATA from NXDOMAIN.
|
||||
if p.store.NameExists(qname) || isApex {
|
||||
// NODATA: name exists, but not with this type.
|
||||
msg.Ns = []dns.RR{p.syntheticSOA(zone)}
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeSuccess, nil
|
||||
}
|
||||
|
||||
// NXDOMAIN: name doesn't exist anywhere in this zone.
|
||||
msg.Rcode = dns.RcodeNameError
|
||||
msg.Ns = []dns.RR{p.syntheticSOA(zone)}
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeNameError, nil
|
||||
}
|
||||
|
||||
// refuseUpdate returns REFUSED for UPDATE messages until Phase 1.4
|
||||
// wires the proper UPDATE handler. We keep a dedicated method so the
|
||||
// "this plugin doesn't yet accept updates" path is searchable in logs.
|
||||
func (p *RFC2136) refuseUpdate(w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||
log.Warningf("UPDATE opcode received but Phase 1.4 not yet implemented — refusing")
|
||||
msg := new(dns.Msg)
|
||||
msg.SetRcode(r, dns.RcodeRefused)
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeRefused, nil
|
||||
}
|
||||
|
||||
// findZone returns the longest matching zone for qname, or "" if qname
|
||||
// is outside all configured zones. The returned zone is in canonical
|
||||
// form (lowercase, trailing dot).
|
||||
func (p *RFC2136) findZone(qname string) string {
|
||||
qname = strings.ToLower(dns.Fqdn(qname))
|
||||
// Longest-suffix wins so nested zones work correctly.
|
||||
var best string
|
||||
for _, z := range p.Zones {
|
||||
if qname == z || strings.HasSuffix(qname, "."+z) {
|
||||
if len(z) > len(best) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// syntheticSOA returns the SOA RR for a zone. Serial is derived from
|
||||
// the store's monotonic generation counter — every UPDATE bumps it,
|
||||
// so downstream observers can detect "something changed" without
|
||||
// having to AXFR.
|
||||
func (p *RFC2136) syntheticSOA(zone string) *dns.SOA {
|
||||
return &dns.SOA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: zone,
|
||||
Rrtype: dns.TypeSOA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: p.TTL,
|
||||
},
|
||||
Ns: p.Nameserver,
|
||||
Mbox: "admin." + zone,
|
||||
Serial: uint32(p.store.generation()),
|
||||
Refresh: 3600, // 1 hour
|
||||
Retry: 600, // 10 min
|
||||
Expire: 604800, // 1 week
|
||||
Minttl: 60, // negative-cache TTL
|
||||
}
|
||||
}
|
||||
|
||||
// syntheticNS returns the NS RRset for a zone. Currently a single NS
|
||||
// pointing at p.Nameserver (the host that runs this plugin). For
|
||||
// resiliency, future versions could accept multiple `nameserver`
|
||||
// directives.
|
||||
func (p *RFC2136) syntheticNS(zone string) []dns.RR {
|
||||
return []dns.RR{
|
||||
&dns.NS{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: zone,
|
||||
Rrtype: dns.TypeNS,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: p.TTL,
|
||||
},
|
||||
Ns: p.Nameserver,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
224
plugin_test.go
Normal file
224
plugin_test.go
Normal file
@ -0,0 +1,224 @@
|
||||
package rfc2136
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/coredns/coredns/plugin"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// captureWriter implements dns.ResponseWriter and stashes the message
|
||||
// passed to WriteMsg so tests can inspect it after ServeDNS returns.
|
||||
type captureWriter struct {
|
||||
msg *dns.Msg
|
||||
}
|
||||
|
||||
func (cw *captureWriter) WriteMsg(m *dns.Msg) error { cw.msg = m; return nil }
|
||||
func (cw *captureWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (cw *captureWriter) Close() error { return nil }
|
||||
func (cw *captureWriter) TsigStatus() error { return nil }
|
||||
func (cw *captureWriter) TsigTimersOnly(bool) {}
|
||||
func (cw *captureWriter) Hijack() {}
|
||||
func (cw *captureWriter) LocalAddr() net.Addr { return &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)} }
|
||||
func (cw *captureWriter) RemoteAddr() net.Addr { return &net.IPAddr{IP: net.IPv4(127, 0, 0, 1)} }
|
||||
func (cw *captureWriter) Network() string { return "udp" }
|
||||
|
||||
// passthroughNext is a stand-in for the next plugin in the chain.
|
||||
// Returns a fixed rcode so we can detect "we passed through" in tests.
|
||||
type passthroughNext struct{ called bool }
|
||||
|
||||
func (n *passthroughNext) ServeDNS(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||
n.called = true
|
||||
msg := new(dns.Msg)
|
||||
msg.SetReply(r)
|
||||
msg.Rcode = dns.RcodeRefused // arbitrary marker
|
||||
_ = w.WriteMsg(msg)
|
||||
return dns.RcodeRefused, nil
|
||||
}
|
||||
func (n *passthroughNext) Name() string { return "passthroughNext" }
|
||||
|
||||
// newTestPlugin builds an RFC2136 with sensible defaults for tests.
|
||||
func newTestPlugin(zone, ns string, next plugin.Handler) *RFC2136 {
|
||||
return &RFC2136{
|
||||
Next: next,
|
||||
Zones: []string{dns.Fqdn(zone)},
|
||||
TTL: 60,
|
||||
Nameserver: dns.Fqdn(ns),
|
||||
store: newStore(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_OutsideZone_PassesThrough(t *testing.T) {
|
||||
next := &passthroughNext{}
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", next)
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("unrelated.other.tld.", dns.TypeA)
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if !next.called {
|
||||
t.Errorf("expected pass-through to Next, but Next was not called")
|
||||
}
|
||||
if rcode != dns.RcodeRefused {
|
||||
t.Errorf("rcode = %d (want %d from passthroughNext marker)", rcode, dns.RcodeRefused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_ApexSOA_Synthetic(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("auth.example.com.", dns.TypeSOA)
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if rcode != dns.RcodeSuccess {
|
||||
t.Fatalf("rcode = %d, want NOERROR", rcode)
|
||||
}
|
||||
if w.msg == nil || !w.msg.Authoritative {
|
||||
t.Fatalf("response not authoritative: %+v", w.msg)
|
||||
}
|
||||
if len(w.msg.Answer) != 1 {
|
||||
t.Fatalf("Answer len = %d, want 1", len(w.msg.Answer))
|
||||
}
|
||||
soa, ok := w.msg.Answer[0].(*dns.SOA)
|
||||
if !ok {
|
||||
t.Fatalf("answer is not SOA: %T", w.msg.Answer[0])
|
||||
}
|
||||
if soa.Ns != "ns.example.com." {
|
||||
t.Errorf("SOA.Ns = %q, want ns.example.com.", soa.Ns)
|
||||
}
|
||||
if soa.Mbox != "admin.auth.example.com." {
|
||||
t.Errorf("SOA.Mbox = %q, want admin.auth.example.com.", soa.Mbox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_ApexNS_Synthetic(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("auth.example.com.", dns.TypeNS)
|
||||
|
||||
w := &captureWriter{}
|
||||
p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if len(w.msg.Answer) != 1 {
|
||||
t.Fatalf("Answer len = %d, want 1", len(w.msg.Answer))
|
||||
}
|
||||
ns, ok := w.msg.Answer[0].(*dns.NS)
|
||||
if !ok {
|
||||
t.Fatalf("answer is not NS: %T", w.msg.Answer[0])
|
||||
}
|
||||
if ns.Ns != "ns.example.com." {
|
||||
t.Errorf("NS.Ns = %q", ns.Ns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_ExistingTXT_Returned(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN TXT "token-1"`))
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("foo.auth.example.com.", dns.TypeTXT)
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if rcode != dns.RcodeSuccess {
|
||||
t.Fatalf("rcode = %d, want NOERROR", rcode)
|
||||
}
|
||||
if len(w.msg.Answer) != 1 {
|
||||
t.Fatalf("Answer len = %d, want 1", len(w.msg.Answer))
|
||||
}
|
||||
txt := w.msg.Answer[0].(*dns.TXT)
|
||||
if txt.Txt[0] != "token-1" {
|
||||
t.Errorf("TXT = %q, want token-1", txt.Txt[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_NonExistentName_NXDOMAIN(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("missing.auth.example.com.", dns.TypeTXT)
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if rcode != dns.RcodeNameError {
|
||||
t.Errorf("rcode = %d, want NXDOMAIN (%d)", rcode, dns.RcodeNameError)
|
||||
}
|
||||
if len(w.msg.Answer) != 0 {
|
||||
t.Errorf("expected empty Answer for NXDOMAIN, got %v", w.msg.Answer)
|
||||
}
|
||||
if len(w.msg.Ns) != 1 {
|
||||
t.Errorf("expected SOA in authority section, got %v", w.msg.Ns)
|
||||
}
|
||||
if _, ok := w.msg.Ns[0].(*dns.SOA); !ok {
|
||||
t.Errorf("authority section is not SOA: %T", w.msg.Ns[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_WrongType_NODATA(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN A 192.0.2.1`))
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion("foo.auth.example.com.", dns.TypeTXT)
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if rcode != dns.RcodeSuccess {
|
||||
t.Errorf("rcode = %d, want NOERROR (NODATA)", rcode)
|
||||
}
|
||||
if len(w.msg.Answer) != 0 {
|
||||
t.Errorf("NODATA must have empty Answer, got %v", w.msg.Answer)
|
||||
}
|
||||
if len(w.msg.Ns) != 1 {
|
||||
t.Errorf("expected SOA in authority for NODATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDNS_UpdateOpcode_Refused(t *testing.T) {
|
||||
p := newTestPlugin("auth.example.com.", "ns.example.com.", nil)
|
||||
|
||||
req := new(dns.Msg)
|
||||
req.SetUpdate("auth.example.com.")
|
||||
|
||||
w := &captureWriter{}
|
||||
rcode, _ := p.ServeDNS(context.Background(), w, req)
|
||||
|
||||
if rcode != dns.RcodeRefused {
|
||||
t.Errorf("UPDATE rcode = %d, want REFUSED (%d)", rcode, dns.RcodeRefused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindZone_LongestSuffixWins(t *testing.T) {
|
||||
p := &RFC2136{
|
||||
Zones: []string{"example.com.", "auth.example.com."},
|
||||
}
|
||||
got := p.findZone("foo.auth.example.com.")
|
||||
if got != "auth.example.com." {
|
||||
t.Errorf("findZone returned %q, expected longest-match auth.example.com.", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindZone_OutsideAllZones(t *testing.T) {
|
||||
p := &RFC2136{Zones: []string{"auth.example.com."}}
|
||||
if got := p.findZone("other.tld."); got != "" {
|
||||
t.Errorf("findZone for unrelated qname returned %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindZone_CaseInsensitive(t *testing.T) {
|
||||
p := &RFC2136{Zones: []string{"auth.example.com."}}
|
||||
if got := p.findZone("Foo.AUTH.example.COM."); got != "auth.example.com." {
|
||||
t.Errorf("case-insensitive findZone returned %q", got)
|
||||
}
|
||||
}
|
||||
17
setup.go
17
setup.go
@ -7,6 +7,7 @@ import (
|
||||
"github.com/coredns/coredns/core/dnsserver"
|
||||
"github.com/coredns/coredns/plugin"
|
||||
clog "github.com/coredns/coredns/plugin/pkg/log"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// log is the package logger, scoped so messages are prefixed `[rfc2136]`.
|
||||
@ -51,6 +52,7 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
|
||||
p := &RFC2136{
|
||||
TSIGKeys: make(map[string]tsigKey),
|
||||
TTL: DefaultTTL,
|
||||
store: newStore(),
|
||||
}
|
||||
|
||||
for c.Next() {
|
||||
@ -68,6 +70,13 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
|
||||
for c.NextBlock() {
|
||||
switch c.Val() {
|
||||
|
||||
case "nameserver":
|
||||
nArgs := c.RemainingArgs()
|
||||
if len(nArgs) != 1 {
|
||||
return nil, c.ArgErr()
|
||||
}
|
||||
p.Nameserver = dns.Fqdn(nArgs[0])
|
||||
|
||||
case "tsig-key":
|
||||
// tsig-key <name> <algorithm> <base64-secret>
|
||||
kArgs := c.RemainingArgs()
|
||||
@ -119,5 +128,13 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
|
||||
return nil, c.Err("at least one zone must be specified")
|
||||
}
|
||||
|
||||
// Default nameserver to the first zone apex. The user can override
|
||||
// via the `nameserver` directive — e.g. when the delegating parent
|
||||
// zone publishes `auth NS dns.supported.systems`, this should be
|
||||
// set to `dns.supported.systems.` to match.
|
||||
if p.Nameserver == "" {
|
||||
p.Nameserver = p.Zones[0]
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@ -158,6 +158,35 @@ func TestParse(t *testing.T) {
|
||||
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. {
|
||||
|
||||
175
store.go
Normal file
175
store.go
Normal file
@ -0,0 +1,175 @@
|
||||
package rfc2136
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// recordStore is the in-memory store of dynamic records. It is keyed
|
||||
// by canonical (lowercased, trailing-dot) owner name, then by RR type,
|
||||
// to a slice of RRs forming the RRset.
|
||||
//
|
||||
// Concurrency: a single RWMutex covers all mutations. Read paths
|
||||
// (queries) are far more frequent than writes (UPDATEs), so the
|
||||
// RW split is the right choice. The lock is held only across the
|
||||
// O(1) map operations; nothing slow happens under the lock.
|
||||
//
|
||||
// Serial: every successful mutation bumps the atomic generation
|
||||
// counter. The synthetic SOA uses this counter so resolvers see a
|
||||
// fresh serial after every UPDATE — even though we don't run AXFR
|
||||
// against secondaries, future use-cases (DNS-NOTIFY, debugging,
|
||||
// cache invalidation) benefit from a monotonic serial.
|
||||
type recordStore struct {
|
||||
mu sync.RWMutex
|
||||
// rrs[name][rrtype] = RRset (slice). The map-of-maps shape means
|
||||
// per-type lookups are O(1), and "name exists at all" is O(1) on
|
||||
// the outer map.
|
||||
rrs map[string]map[uint16][]dns.RR
|
||||
|
||||
gen atomic.Uint64
|
||||
}
|
||||
|
||||
func newStore() *recordStore {
|
||||
return &recordStore{rrs: make(map[string]map[uint16][]dns.RR)}
|
||||
}
|
||||
|
||||
// generation returns the current monotonic counter. Used for SOA serial.
|
||||
func (s *recordStore) generation() uint64 {
|
||||
return s.gen.Load()
|
||||
}
|
||||
|
||||
// canon normalises a name to the store's internal form: lowercase +
|
||||
// trailing dot. miekg/dns sometimes returns names without the dot;
|
||||
// always passing through this keeps everything consistent.
|
||||
func canon(name string) string {
|
||||
return strings.ToLower(dns.Fqdn(name))
|
||||
}
|
||||
|
||||
// Add inserts a single RR into the store. Duplicate RRs (same owner,
|
||||
// type, AND rdata) are silently de-duplicated — matches RFC 2136 §3.4.2.2
|
||||
// behavior for `add` semantics.
|
||||
func (s *recordStore) Add(rr dns.RR) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
name := canon(rr.Header().Name)
|
||||
rtype := rr.Header().Rrtype
|
||||
|
||||
byType, ok := s.rrs[name]
|
||||
if !ok {
|
||||
byType = make(map[uint16][]dns.RR)
|
||||
s.rrs[name] = byType
|
||||
}
|
||||
|
||||
// De-duplicate by string comparison of the rdata-bearing form.
|
||||
rrStr := rr.String()
|
||||
for _, existing := range byType[rtype] {
|
||||
if existing.String() == rrStr {
|
||||
return
|
||||
}
|
||||
}
|
||||
byType[rtype] = append(byType[rtype], rr)
|
||||
s.gen.Add(1)
|
||||
}
|
||||
|
||||
// RemoveRRset deletes ALL records of the given (name, type) — i.e.
|
||||
// "delete the RRset" semantics from RFC 2136 §3.4.2.3. If the name
|
||||
// has no records left after this, the name entry is reaped so
|
||||
// NameExists returns false.
|
||||
func (s *recordStore) RemoveRRset(name string, rtype uint16) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
name = canon(name)
|
||||
byType, ok := s.rrs[name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, hadType := byType[rtype]; !hadType {
|
||||
return
|
||||
}
|
||||
delete(byType, rtype)
|
||||
if len(byType) == 0 {
|
||||
delete(s.rrs, name)
|
||||
}
|
||||
s.gen.Add(1)
|
||||
}
|
||||
|
||||
// RemoveRR deletes one specific RR (matching owner, type, and rdata).
|
||||
// This implements RFC 2136 §3.4.2.4 "delete an RR from an RRset".
|
||||
func (s *recordStore) RemoveRR(rr dns.RR) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
name := canon(rr.Header().Name)
|
||||
rtype := rr.Header().Rrtype
|
||||
byType, ok := s.rrs[name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rrs := byType[rtype]
|
||||
target := rr.String()
|
||||
for i, existing := range rrs {
|
||||
if existing.String() == target {
|
||||
byType[rtype] = append(rrs[:i], rrs[i+1:]...)
|
||||
if len(byType[rtype]) == 0 {
|
||||
delete(byType, rtype)
|
||||
}
|
||||
if len(byType) == 0 {
|
||||
delete(s.rrs, name)
|
||||
}
|
||||
s.gen.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveName deletes all records for an owner name (§3.4.2.3 "delete
|
||||
// all RRsets from a name").
|
||||
func (s *recordStore) RemoveName(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
name = canon(name)
|
||||
if _, ok := s.rrs[name]; !ok {
|
||||
return
|
||||
}
|
||||
delete(s.rrs, name)
|
||||
s.gen.Add(1)
|
||||
}
|
||||
|
||||
// Lookup returns the RRset for (name, rtype). Returns nil for both
|
||||
// "name doesn't exist" and "name exists with other types but not this
|
||||
// one" — use NameExists to distinguish NODATA from NXDOMAIN.
|
||||
//
|
||||
// The returned slice is a copy so callers can freely mutate it without
|
||||
// affecting store state.
|
||||
func (s *recordStore) Lookup(name string, rtype uint16) []dns.RR {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
byType, ok := s.rrs[canon(name)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rrs := byType[rtype]
|
||||
if len(rrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dns.RR, len(rrs))
|
||||
copy(out, rrs)
|
||||
return out
|
||||
}
|
||||
|
||||
// NameExists reports whether ANY records exist for the given name.
|
||||
// Used to distinguish NODATA (name exists, no records of asked type)
|
||||
// from NXDOMAIN (name doesn't exist at all).
|
||||
func (s *recordStore) NameExists(name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.rrs[canon(name)]
|
||||
return ok
|
||||
}
|
||||
180
store_test.go
Normal file
180
store_test.go
Normal file
@ -0,0 +1,180 @@
|
||||
package rfc2136
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func mustRR(t *testing.T, s string) dns.RR {
|
||||
t.Helper()
|
||||
rr, err := dns.NewRR(s)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse RR %q: %v", s, err)
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestStore_AddLookup(t *testing.T) {
|
||||
s := newStore()
|
||||
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "token-1"`))
|
||||
|
||||
got := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Lookup TXT: got %d records, want 1", len(got))
|
||||
}
|
||||
if got[0].String() != `foo.example.com. 60 IN TXT "token-1"` {
|
||||
t.Errorf("unexpected RR: %s", got[0].String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddMultipleRRsetEntries(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "token-1"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "token-2"`))
|
||||
|
||||
got := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("RRset size = %d, want 2 (both TXT values)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddDedupes(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "same"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "same"`))
|
||||
|
||||
got := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
if len(got) != 1 {
|
||||
t.Errorf("RRset size = %d, want 1 (duplicate ignored)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LookupCaseInsensitive(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `FOO.example.com. 60 IN TXT "token"`))
|
||||
|
||||
if got := s.Lookup("foo.EXAMPLE.com.", dns.TypeTXT); len(got) != 1 {
|
||||
t.Errorf("case-insensitive lookup failed: got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LookupMissingNameReturnsNil(t *testing.T) {
|
||||
s := newStore()
|
||||
if got := s.Lookup("nope.example.com.", dns.TypeTXT); got != nil {
|
||||
t.Errorf("expected nil for missing name, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LookupNameExistsWrongTypeReturnsNil(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`))
|
||||
|
||||
if got := s.Lookup("foo.example.com.", dns.TypeTXT); got != nil {
|
||||
t.Errorf("expected nil for wrong type (A exists but not TXT), got %v", got)
|
||||
}
|
||||
if !s.NameExists("foo.example.com.") {
|
||||
t.Errorf("NameExists should return true (A record exists)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RemoveRRset(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "a"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "b"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`))
|
||||
|
||||
s.RemoveRRset("foo.example.com.", dns.TypeTXT)
|
||||
|
||||
if got := s.Lookup("foo.example.com.", dns.TypeTXT); got != nil {
|
||||
t.Errorf("TXT RRset should be gone, got %v", got)
|
||||
}
|
||||
if got := s.Lookup("foo.example.com.", dns.TypeA); len(got) != 1 {
|
||||
t.Errorf("A record should survive RRset deletion, got %v", got)
|
||||
}
|
||||
if !s.NameExists("foo.example.com.") {
|
||||
t.Errorf("name should still exist (A remains)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RemoveRRsetReapsEmptyName(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "a"`))
|
||||
s.RemoveRRset("foo.example.com.", dns.TypeTXT)
|
||||
|
||||
if s.NameExists("foo.example.com.") {
|
||||
t.Errorf("name should have been reaped after last RRset removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RemoveRR(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "keep"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "drop"`))
|
||||
|
||||
s.RemoveRR(mustRR(t, `foo.example.com. 60 IN TXT "drop"`))
|
||||
|
||||
got := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("RRset size after RemoveRR = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].(*dns.TXT).Txt[0] != "keep" {
|
||||
t.Errorf("wrong RR remained: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RemoveName(t *testing.T) {
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "a"`))
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`))
|
||||
|
||||
s.RemoveName("foo.example.com.")
|
||||
|
||||
if s.NameExists("foo.example.com.") {
|
||||
t.Errorf("name should be gone after RemoveName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GenerationBumpsOnMutation(t *testing.T) {
|
||||
s := newStore()
|
||||
start := s.generation()
|
||||
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "a"`))
|
||||
if s.generation() != start+1 {
|
||||
t.Errorf("generation after Add: %d, want %d", s.generation(), start+1)
|
||||
}
|
||||
|
||||
// Re-adding the same RR is a no-op → generation must NOT bump.
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "a"`))
|
||||
if s.generation() != start+1 {
|
||||
t.Errorf("generation after duplicate Add: %d, want %d (no bump)", s.generation(), start+1)
|
||||
}
|
||||
|
||||
s.RemoveRRset("foo.example.com.", dns.TypeTXT)
|
||||
if s.generation() != start+2 {
|
||||
t.Errorf("generation after RemoveRRset: %d, want %d", s.generation(), start+2)
|
||||
}
|
||||
|
||||
// Removing again is a no-op → no bump.
|
||||
s.RemoveRRset("foo.example.com.", dns.TypeTXT)
|
||||
if s.generation() != start+2 {
|
||||
t.Errorf("generation after no-op RemoveRRset: %d, want %d", s.generation(), start+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LookupReturnsCopy(t *testing.T) {
|
||||
// The returned slice must be a copy so external mutations don't
|
||||
// affect store state.
|
||||
s := newStore()
|
||||
s.Add(mustRR(t, `foo.example.com. 60 IN TXT "original"`))
|
||||
|
||||
got := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
got[0] = mustRR(t, `foo.example.com. 60 IN TXT "tampered"`)
|
||||
|
||||
// Re-lookup should still see the original.
|
||||
again := s.Lookup("foo.example.com.", dns.TypeTXT)
|
||||
if again[0].(*dns.TXT).Txt[0] != "original" {
|
||||
t.Errorf("Lookup returned shared slice — store corrupted by external mutation: %v", again[0])
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user