package rfc2136 import ( "net" "os" "path/filepath" "strings" "testing" "github.com/miekg/dns" ) // captureWriter implements dns.ResponseWriter and stashes the message // passed to WriteMsg so tests can inspect it after handleUpdate 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" } // newTestPluginWithZone builds an RFC2136 backed by a temp zones-dir. // AutoCommit is disabled by default (tests don't need git side-effects). func newTestPluginWithZone(t *testing.T, zone string) *RFC2136 { t.Helper() dir := withTempZonesDir(t, zone) zone = dns.Fqdn(zone) p := &RFC2136{ Zones: []string{zone}, TTL: 60, ZonesDir: dir, zones: map[string]*zoneFile{ zone: openZoneFile(filepath.Join(dir, strings.TrimSuffix(zone, ".")+".zone"), zone), }, } p.zones[zone].AutoCommit = false return p } // newUpdate builds an UPDATE message for `zone` with zero prereqs and // the given update RRs. Matches what caddy-dns/rfc2136 sends. func newUpdate(zone string, updates ...dns.RR) *dns.Msg { m := new(dns.Msg) m.SetUpdate(dns.Fqdn(zone)) m.Ns = append(m.Ns, updates...) return m } // runUpdate sends msg through the handler with TSIG auth bypassed // (calling handleUpdate directly instead of ServeDNS). func runUpdate(t *testing.T, p *RFC2136, msg *dns.Msg) (rcode int) { t.Helper() w := &captureWriter{} rcode, _ = p.handleUpdate(w, msg) return rcode } // readZoneRecords loads the zone file and returns the RRs for inspection. func readZoneRecords(t *testing.T, p *RFC2136, zone string) []dns.RR { t.Helper() zf := p.zones[dns.Fqdn(zone)] rrs, err := zf.loadRRs() if err != nil { t.Fatalf("loadRRs: %v", err) } return rrs } func TestUpdate_AddSingleTXT_PersistsToFile(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") upd := newUpdate("auth.example.com", mustRR(t, `token-1.auth.example.com. 60 IN TXT "validation-1"`), ) if rcode := runUpdate(t, p, upd); rcode != dns.RcodeSuccess { t.Fatalf("rcode = %d, want NOERROR", rcode) } // Verify by re-reading the file. rrs := readZoneRecords(t, p, "auth.example.com") for _, rr := range rrs { if txt, ok := rr.(*dns.TXT); ok && txt.Hdr.Name == "token-1.auth.example.com." { if txt.Txt[0] == "validation-1" { return // success } } } t.Errorf("added TXT not found in re-read zone file") } func TestUpdate_AddBumpsSerial(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") before := readZoneRecords(t, p, "auth.example.com") var beforeSerial uint32 for _, rr := range before { if soa, ok := rr.(*dns.SOA); ok { beforeSerial = soa.Serial break } } upd := newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), ) runUpdate(t, p, upd) after := readZoneRecords(t, p, "auth.example.com") var afterSerial uint32 for _, rr := range after { if soa, ok := rr.(*dns.SOA); ok { afterSerial = soa.Serial break } } if afterSerial <= beforeSerial { t.Errorf("Serial did not advance: before=%d after=%d", beforeSerial, afterSerial) } } func TestUpdate_DeleteRRset_RemovesAllOfType(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") // First add two TXTs at the same name. runUpdate(t, p, newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "a"`), mustRR(t, `foo.auth.example.com. 60 IN TXT "b"`), )) // Now delete the whole TXT RRset. del := &dns.ANY{Hdr: dns.RR_Header{ Name: "foo.auth.example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassANY, }} if rcode := runUpdate(t, p, newUpdate("auth.example.com", del)); rcode != dns.RcodeSuccess { t.Fatalf("delete rcode = %d", rcode) } for _, rr := range readZoneRecords(t, p, "auth.example.com") { if rr.Header().Rrtype == dns.TypeTXT && rr.Header().Name == "foo.auth.example.com." { t.Errorf("TXT %s should be gone, still present: %s", rr.Header().Name, rr.String()) } } } func TestUpdate_OutOfZone_Refused(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") upd := newUpdate("auth.example.com", mustRR(t, `evil.other.tld. 60 IN TXT "nope"`), ) if rcode := runUpdate(t, p, upd); rcode != dns.RcodeNotZone { t.Errorf("rcode = %d, want NOTZONE (%d)", rcode, dns.RcodeNotZone) } } func TestUpdate_UnknownZone_NOTAUTH(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") // Build UPDATE for a zone we don't manage. m := new(dns.Msg) m.SetUpdate("other.zone.") m.Ns = []dns.RR{mustRR(t, `x.other.zone. 60 IN TXT "y"`)} if rcode := runUpdate(t, p, m); rcode != dns.RcodeNotAuth { t.Errorf("rcode = %d, want NOTAUTH (%d)", rcode, dns.RcodeNotAuth) } } func TestUpdate_ApexSOADeletion_Refused(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") // CLASS=ANY type=SOA at apex → would wipe the SOA. del := &dns.ANY{Hdr: dns.RR_Header{ Name: "auth.example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassANY, }} if rcode := runUpdate(t, p, newUpdate("auth.example.com", del)); rcode != dns.RcodeRefused { t.Errorf("rcode = %d, want REFUSED", rcode) } // SOA must still be in the file. hasSOA := false for _, rr := range readZoneRecords(t, p, "auth.example.com") { if _, ok := rr.(*dns.SOA); ok { hasSOA = true break } } if !hasSOA { t.Errorf("SOA was deleted despite REFUSED rcode") } } func TestUpdate_ApexWipe_Refused(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") del := &dns.ANY{Hdr: dns.RR_Header{ Name: "auth.example.com.", Rrtype: dns.TypeANY, Class: dns.ClassANY, }} if rcode := runUpdate(t, p, newUpdate("auth.example.com", del)); rcode != dns.RcodeRefused { t.Errorf("rcode = %d, want REFUSED", rcode) } } func TestUpdate_PrereqRRsetMustNotExist_YXRRSET(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") // Seed a TXT then send an UPDATE whose prereq says "no TXT here". runUpdate(t, p, newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "present"`), )) prereq := &dns.ANY{Hdr: dns.RR_Header{ Name: "foo.auth.example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassNONE, }} m := new(dns.Msg) m.SetUpdate("auth.example.com.") m.Answer = []dns.RR{prereq} m.Ns = []dns.RR{mustRR(t, `foo.auth.example.com. 60 IN TXT "should-not-be-added"`)} if rcode := runUpdate(t, p, m); rcode != dns.RcodeYXRrset { t.Errorf("rcode = %d, want YXRRSET", rcode) } } func TestUpdate_NoOpAdd_DoesntRewriteFile(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") // Add a record once. runUpdate(t, p, newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), )) path := p.zones["auth.example.com."].Path beforeStat, _ := os.Stat(path) // Same UPDATE again — should be a no-op (dedupe). runUpdate(t, p, newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), )) afterStat, _ := os.Stat(path) if afterStat.ModTime().After(beforeStat.ModTime()) { t.Errorf("file was rewritten despite no-op update (mtime advanced)") } } // TestUpdate_SignedRequest_ResponseGetsTSIG verifies the RFC 8945 §5.4.2 // requirement that a server's response to a TSIG-signed request must // itself carry TSIG so the client can authenticate the answer. Before // this fix, BIND nsupdate complained "expected a TSIG or SIG(0)" because // the response went back without any TSIG record attached. func TestUpdate_SignedRequest_ResponseGetsTSIG(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") upd := newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), ) // Attach a TSIG to the request — the values mimic what nsupdate // or caddy-dns/rfc2136 emit. handleUpdate runs WITHOUT MAC // verification in this test (that's ServeDNS's job and is // covered separately), so the TSIG record's MAC contents don't // matter — only the presence/algorithm/name do. upd.SetTsig("acme-update-key.", dns.HmacSHA256, 300, 0) w := &captureWriter{} if _, err := p.handleUpdate(w, upd); err != nil { t.Fatalf("handleUpdate: %v", err) } if w.msg == nil { t.Fatal("response was not written") } got := w.msg.IsTsig() if got == nil { t.Fatal("response missing TSIG — nsupdate would reject as 'expected a TSIG or SIG(0)'") } if !strings.EqualFold(got.Hdr.Name, "acme-update-key.") { t.Errorf("response TSIG key = %q, want acme-update-key.", got.Hdr.Name) } if !strings.EqualFold(got.Algorithm, dns.HmacSHA256) { t.Errorf("response TSIG alg = %q, want %s", got.Algorithm, dns.HmacSHA256) } } // TestUpdate_UnsignedRequest_ResponseStaysUnsigned guards the no-op // branch — if the client didn't sign, we don't synthesize a TSIG on // the response (we couldn't pick a key anyway). func TestUpdate_UnsignedRequest_ResponseStaysUnsigned(t *testing.T) { p := newTestPluginWithZone(t, "auth.example.com") upd := newUpdate("auth.example.com", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), ) // No SetTsig — request is plain. w := &captureWriter{} if _, err := p.handleUpdate(w, upd); err != nil { t.Fatalf("handleUpdate: %v", err) } if w.msg.IsTsig() != nil { t.Error("response carries TSIG despite unsigned request") } } func TestFindZone_LongestSuffixWins(t *testing.T) { p := &RFC2136{Zones: []string{"example.com.", "auth.example.com."}} if got := p.findZone("foo.auth.example.com."); got != "auth.example.com." { t.Errorf("findZone returned %q, expected longest match", got) } } func TestFindZone_OutsideAllZones(t *testing.T) { p := &RFC2136{Zones: []string{"auth.example.com."}} if got := p.findZone("other.tld."); got != "" { t.Errorf("findZone 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) } }