diff --git a/plugin.go b/plugin.go index c2498de..4ea1a17 100644 --- a/plugin.go +++ b/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, + }, + } } diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..3aa40af --- /dev/null +++ b/plugin_test.go @@ -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) + } +} diff --git a/setup.go b/setup.go index 958d980..b83dbaf 100644 --- a/setup.go +++ b/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 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 } diff --git a/setup_test.go b/setup_test.go index 0fd1008..2bcbce2 100644 --- a/setup_test.go +++ b/setup_test.go @@ -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. { diff --git a/store.go b/store.go new file mode 100644 index 0000000..0cac4cb --- /dev/null +++ b/store.go @@ -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 +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..dbb759c --- /dev/null +++ b/store_test.go @@ -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]) + } +}