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:
Ryan Malloy 2026-05-21 10:37:48 -06:00
parent eba6313ec0
commit 1cca9a5aa7
6 changed files with 788 additions and 22 deletions

185
plugin.go
View File

@ -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
View 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)
}
}

View File

@ -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
}

View File

@ -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
View 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
View 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])
}
}