diff --git a/internal/localzone/localzone_test.go b/internal/localzone/localzone_test.go new file mode 100644 index 0000000..93b6dd5 --- /dev/null +++ b/internal/localzone/localzone_test.go @@ -0,0 +1,900 @@ +package localzone + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" +) + +// baseZone is the fixture most tests share: an apex with an SOA and an NS set, +// a host with an address, a name that exists without one, a CNAME into the +// zone, a CNAME loop, a self-referencing CNAME and a delegation. rdata names +// are written fully qualified, as any exported master file has them, and every +// record carries an explicit TTL so the TTL assertions do not depend on a $TTL +// default. +const baseZone = `$ORIGIN example.com. +@ 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300 +@ 3600 IN NS ns1.example.com. +ns1 3600 IN A 192.0.2.53 +host 300 IN A 192.0.2.1 +host 300 IN AAAA 2001:db8::1 +www 300 IN CNAME host +alias 300 IN CNAME loop-a +loop-a 300 IN CNAME alias +self 300 IN CNAME self +off 300 IN CNAME elsewhere.test. +text 300 IN TXT "a node with no address" +sub 3600 IN NS ns1.sub.example.com. +ns1.sub 3600 IN A 192.0.2.60 +` + +// wildZone is baseZone plus two wildcards ("*.example.com." and the closer +// "*.y.example.com."), a node that holds only a subdomain ("y") and a node that +// exists without an address ("b"). +const wildZone = `$ORIGIN example.com. +@ 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300 +@ 3600 IN NS ns1.example.com. +host 300 IN A 192.0.2.1 +b 300 IN TXT "b node" +y 300 IN TXT "y node" +sub 3600 IN NS ns1.sub.example.com. +*.y.example.com. 300 IN A 192.0.2.98 +*.example.com. 300 IN A 192.0.2.99 +` + +// classZone carries one CH record: the store indexes records by name and type +// only, so the class has to travel with the record instead. +const classZone = `$ORIGIN example.com. +@ 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300 +@ 3600 IN NS ns1.example.com. +host 300 IN A 192.0.2.1 +weird 300 CH A 192.0.2.99 +` + +// otherZone is a second, unrelated zone, used to check that a lookup picks the +// zone that actually contains the name. +const otherZone = `$ORIGIN other.test. +@ 3600 IN SOA ns1.other.test. hostmaster.other.test. 2024010101 7200 3600 1209600 300 +@ 3600 IN NS ns1.other.test. +ns1 300 IN A 192.0.2.54 +host 300 IN A 192.0.2.99 +` + +// soaText is the SOA of both fixtures, spelled out so the tests can compare the +// Authority section of a negative answer against the same record in text form. +const soaText = "example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300" + +// writeZone writes text into a fresh t.TempDir() as name and returns the path. +func writeZone(t *testing.T, name, text string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(text), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + return p +} + +// mustLoad compiles paths and fails the test on error. +func mustLoad(t *testing.T, paths []string, opt Options) *Store { + t.Helper() + s, err := Load(paths, opt) + if err != nil { + t.Fatalf("Load(%q): unexpected error: %v", paths, err) + } + if s == nil { + t.Fatalf("Load(%q) returned a nil store", paths) + } + return s +} + +// loadBase compiles baseZone as a single master file. +func loadBase(t *testing.T) *Store { + t.Helper() + return mustLoad(t, []string{writeZone(t, "example.com.zone", baseZone)}, Options{}) +} + +func mustRR(t *testing.T, text string) dns.RR { + t.Helper() + rr, err := dns.NewRR(text) + if err != nil { + t.Fatalf("dns.NewRR(%q): %v", text, err) + } + return rr +} + +// rrText renders a record as it appears in a message section. +func rrText(t *testing.T, text string) string { + t.Helper() + return mustRR(t, text).String() +} + +func q(name string, qtype uint16) dns.Question { + return dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET} +} + +// reply mirrors internal/engine.answerLocal, so the tests can assert on the +// *dns.Msg the query path would send -- Rcode, AA and the three sections -- +// instead of on the Result struct alone. The engine's copy is unexported, so +// the rules are restated here; the two must stay in step. +func reply(store *Store, question dns.Question) (*dns.Msg, bool) { + res := store.Lookup(question) + if res.Delegated { + return nil, false + } + m := new(dns.Msg) + m.Id = dns.Id() + m.Response = true + m.RecursionDesired = true + m.RecursionAvailable = true + m.Authoritative = true + m.Question = []dns.Question{question} + if !res.Found { + if res.Zone == "" { + // Not a name this store serves: the caller must ask upstream. + return nil, false + } + if res.Authority != nil { + m.Rcode = dns.RcodeNameError + m.Ns = []dns.RR{res.Authority} + } + return m, true + } + if len(res.Records) == 0 { + if res.Authority != nil { + m.Ns = []dns.RR{res.Authority} + } + return m, true + } + if question.Qtype != res.Type { + // A CNAME stands in for the type that was asked for. + for _, rr := range res.Records { + if rr.Header().Rrtype == dns.TypeCNAME { + m.Answer = []dns.RR{rr} + return m, true + } + } + } + m.Answer = res.Records + return m, true +} + +// ask returns the reply for a question that must be answered from local data. +func ask(t *testing.T, store *Store, name string, qtype uint16) *dns.Msg { + t.Helper() + m, ok := reply(store, q(name, qtype)) + if !ok { + t.Fatalf("%s/%s: expected a local answer, got a referral or a pass-through", name, dns.TypeToString[qtype]) + } + return m +} + +// want describes the Rcode and sections of a reply. +type want struct { + rcode int + answer []string + authority []string + extra []string +} + +func rrs(rrset []dns.RR) []string { + out := make([]string, 0, len(rrset)) + for _, rr := range rrset { + out = append(out, rr.String()) + } + return out +} + +func checkMsg(t *testing.T, m *dns.Msg, w want) { + t.Helper() + if !m.Response { + t.Errorf("QR bit not set") + } + if !m.Authoritative { + t.Errorf("AA bit not set on %s/%s", m.Question[0].Name, dns.TypeToString[m.Question[0].Qtype]) + } + if m.Rcode != w.rcode { + t.Errorf("Rcode = %s, want %s", dns.RcodeToString[m.Rcode], dns.RcodeToString[w.rcode]) + } + if got := rrs(m.Answer); !slices.Equal(got, w.answer) { + t.Errorf("Answer mismatch\n got: %q\nwant: %q", got, w.answer) + } + if got := rrs(m.Ns); !slices.Equal(got, w.authority) { + t.Errorf("Authority mismatch\n got: %q\nwant: %q", got, w.authority) + } + if got := rrs(m.Extra); !slices.Equal(got, w.extra) { + t.Errorf("Extra mismatch\n got: %q\nwant: %q", got, w.extra) + } +} + +// refer asserts a question at or below a delegation point is not answered +// locally: the query path must fall through to the delegated servers, and the +// Result must say so without carrying any records. +func refer(t *testing.T, store *Store, name string, qtype uint16) { + t.Helper() + if m, ok := reply(store, q(name, qtype)); ok { + t.Errorf("%s/%s: expected a referral, got a reply with rcode %s and answer %q", + name, dns.TypeToString[qtype], dns.RcodeToString[m.Rcode], rrs(m.Answer)) + } + res := store.Lookup(q(name, qtype)) + if !res.Delegated { + t.Errorf("%s/%s: Lookup().Delegated = false, want true", name, dns.TypeToString[qtype]) + } + if res.Found || len(res.Records) != 0 { + t.Errorf("%s/%s: delegated lookup answered with %v", name, dns.TypeToString[qtype], res.Records) + } + if res.Zone != "example.com." { + t.Errorf("%s/%s: delegated lookup Zone = %q, want %q", name, dns.TypeToString[qtype], res.Zone, "example.com.") + } + if res.Authority == nil { + t.Errorf("%s/%s: delegated lookup carries no SOA for the referral", name, dns.TypeToString[qtype]) + } +} + +func TestLoadCompilesMasterFile(t *testing.T) { + p := writeZone(t, "example.com.zone", baseZone) + before := time.Now() + s := mustLoad(t, []string{p}, Options{}) + after := time.Now() + + // The $ORIGIN in the file decides the apex, not the file name. + if got := s.Zones(); !slices.Equal(got, []string{"example.com."}) { + t.Errorf("Zones() = %q, want [example.com.]", got) + } + if got := s.Files(); !slices.Equal(got, []string{p}) { + t.Errorf("Files() = %q, want [%s]", got, p) + } + if s.BuiltAt().Before(before) || s.BuiltAt().After(after) { + t.Errorf("BuiltAt() = %v, want between %v and %v", s.BuiltAt(), before, after) + } + // Every record except the SOA is counted: the SOA is zone metadata. + if got, want := s.Count(), 12; got != want { + t.Errorf("Count() = %d, want %d", got, want) + } + // The smallest TTL in the file, used for referrals and negative answers. + if res := s.Lookup(q("gone.example.com", dns.TypeA)); res.TTL != 300 { + t.Errorf("negative-answer TTL = %d, want the zone minimum 300", res.TTL) + } +} + +func TestLookupExactAnswer(t *testing.T) { + s := loadBase(t) + + m := ask(t, s, "host.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "host.example.com. 300 IN A 192.0.2.1")}}) + + m = ask(t, s, "host.example.com", dns.TypeAAAA) + checkMsg(t, m, want{answer: []string{rrText(t, "host.example.com. 300 IN AAAA 2001:db8::1")}}) + + // The apex NS set is served from local data, not treated as a delegation. + m = ask(t, s, "example.com", dns.TypeNS) + checkMsg(t, m, want{answer: []string{rrText(t, "example.com. 3600 IN NS ns1.example.com.")}}) + + res := s.Lookup(q("host.example.com", dns.TypeA)) + if !res.Found || res.Type != dns.TypeA || res.Zone != "example.com." { + t.Errorf("Lookup(host/A) = %+v, want found A records from example.com.", res) + } + if res.TTL != 300 { + t.Errorf("Lookup(host/A).TTL = %d, want 300", res.TTL) + } + if res.Delegated { + t.Errorf("Lookup(host/A).Delegated = true, want false") + } + + // Names are matched case-insensitively; the answer keeps the stored form. + upper := s.Lookup(q("HOST.Example.COM.", dns.TypeA)) + if !upper.Found || len(upper.Records) != 1 { + t.Errorf("Lookup(HOST.Example.COM./A) = %+v, want the host A record", upper) + } +} + +func TestDelegationCut(t *testing.T) { + s := loadBase(t) + nsSet := rrText(t, "sub.example.com. 3600 IN NS ns1.sub.example.com.") + + t.Run("below the cut is a referral", func(t *testing.T) { + refer(t, s, "host.sub.example.com", dns.TypeA) + refer(t, s, "anything.sub.example.com", dns.TypeTXT) + }) + + t.Run("glue below the cut is not answered from the parent", func(t *testing.T) { + // ns1.sub is in the file, but the address belongs to the child zone. + refer(t, s, "ns1.sub.example.com", dns.TypeA) + }) + + t.Run("the cut itself answers NS authoritatively", func(t *testing.T) { + m := ask(t, s, "sub.example.com", dns.TypeNS) + checkMsg(t, m, want{answer: []string{nsSet}}) + }) + + t.Run("the cut refers for other types", func(t *testing.T) { + refer(t, s, "sub.example.com", dns.TypeA) + refer(t, s, "sub.example.com", dns.TypeSOA) + }) +} + +func TestNODATAVersusNXDOMAIN(t *testing.T) { + s := loadBase(t) + soa := rrText(t, soaText) + + // The two negative answers are asserted side by side: both carry the SOA + // and an empty Answer section, and only the Rcode (plus the Found flag the + // engine branches on) tells them apart. + tests := []struct { + name string + qtype uint16 + // found is the Lookup flag the engine branches on: NODATA means the + // name exists, NXDOMAIN does not. + found bool + w want + }{ + {name: "host.example.com", qtype: dns.TypeMX, found: true, w: want{authority: []string{soa}}}, + {name: "text.example.com", qtype: dns.TypeA, found: true, w: want{authority: []string{soa}}}, + {name: "text.example.com", qtype: dns.TypeAAAA, found: true, w: want{authority: []string{soa}}}, + // The apex exists (SOA and NS), so it cannot be NXDOMAIN. + {name: "example.com", qtype: dns.TypeA, found: true, w: want{authority: []string{soa}}}, + {name: "example.com", qtype: dns.TypeMX, found: true, w: want{authority: []string{soa}}}, + {name: "gone.example.com", qtype: dns.TypeA, found: false, w: want{rcode: dns.RcodeNameError, authority: []string{soa}}}, + {name: "x.text.example.com", qtype: dns.TypeA, found: false, w: want{rcode: dns.RcodeNameError, authority: []string{soa}}}, + } + for _, tc := range tests { + name, qtype := dns.Fqdn(tc.name), tc.qtype + w := tc.w + if w.rcode == 0 { + w.rcode = dns.RcodeSuccess + } + m := ask(t, s, name, qtype) + checkMsg(t, m, w) + res := s.Lookup(q(name, qtype)) + if res.Found != tc.found { + t.Errorf("%s/%s: Lookup().Found = %v, want %v", name, dns.TypeToString[qtype], res.Found, tc.found) + } + if len(res.Records) != 0 { + t.Errorf("%s/%s: negative answer carries records %v", name, dns.TypeToString[qtype], rrs(res.Records)) + } + if res.Authority == nil { + t.Errorf("%s/%s: negative answer carries no SOA", name, dns.TypeToString[qtype]) + } + if res.Zone != "example.com." { + t.Errorf("%s/%s: Lookup().Zone = %q", name, dns.TypeToString[qtype], res.Zone) + } + } +} + +func TestWildcardClosestEncloser(t *testing.T) { + // The wildcards live in a second file for the same zone, which is how an + // operator splits a zone across files. + first := writeZone(t, "example.com.zone", baseZone) + dir := t.TempDir() + extra := filepath.Join(dir, "wildcards.zone") + if err := os.WriteFile(extra, []byte(`$ORIGIN example.com. +*.y.example.com. 300 IN A 192.0.2.98 +*.example.com. 300 IN A 192.0.2.99 +y 300 IN TXT "y node" +b 300 IN TXT "b node" +`), 0o600); err != nil { + t.Fatalf("write %s: %v", extra, err) + } + s := mustLoad(t, []string{first, extra}, Options{}) + if got := s.Zones(); !slices.Equal(got, []string{"example.com."}) { + t.Fatalf("Zones() = %q, want one merged zone", got) + } + + // "x.y.example.com" does not exist, so the closest wildcard wins over the + // apex one, and the answer is labelled with the queried name. + m := ask(t, s, "x.y.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "x.y.example.com. 300 IN A 192.0.2.98")}}) + + // A name with nothing but the apex wildcard above it gets the apex one. + m = ask(t, s, "x.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "x.example.com. 300 IN A 192.0.2.99")}}) + + // Synthesising the answer must not rewrite the stored wildcard record, or + // the next wildcard answer would carry the previous name. + if got := s.Records("example.com"); !slices.Contains(got, rrText(t, "*.example.com. 300 IN A 192.0.2.99")) { + t.Errorf("Records() lost the wildcard record: %q", got) + } + again := ask(t, s, "z.example.com", dns.TypeA) + checkMsg(t, again, want{answer: []string{rrText(t, "z.example.com. 300 IN A 192.0.2.99")}}) + + // A wildcard is chosen by whole labels: y.example.com. is not an ancestor + // of x.y.z.example.com., so the closest encloser is the apex and the apex + // wildcard is the one that applies. + m = ask(t, s, "x.y.z.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "x.y.z.example.com. 300 IN A 192.0.2.99")}}) +} + +func TestWildcardDoesNotMatchAnExistingName(t *testing.T) { + s := mustLoad(t, []string{writeZone(t, "example.com.zone", wildZone)}, Options{}) + soa := rrText(t, soaText) + + // A wildcard at the apex does not match the apex itself. + m := ask(t, s, "example.com", dns.TypeA) + checkMsg(t, m, want{authority: []string{soa}}) + m = ask(t, s, "example.com", dns.TypeMX) + checkMsg(t, m, want{authority: []string{soa}}) + + // Nor does it fill a type into a name that already exists. + m = ask(t, s, "b.example.com", dns.TypeA) + checkMsg(t, m, want{authority: []string{soa}}) + if res := s.Lookup(q("b.example.com", dns.TypeA)); !res.Found || len(res.Records) != 0 { + t.Errorf("Lookup(b/A) = %+v, want an existing name with no records", res) + } + + // And it never reaches past a name that exists: b.example.com is the + // closest encloser of x.b.example.com, and there is no *.b.example.com. + m = ask(t, s, "x.b.example.com", dns.TypeA) + checkMsg(t, m, want{rcode: dns.RcodeNameError, authority: []string{soa}}) + + // The wildcard still answers an ordinary name below the apex. + m = ask(t, s, "www.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "www.example.com. 300 IN A 192.0.2.99")}}) +} + +func TestWildcardDoesNotLeakPastADelegation(t *testing.T) { + s := mustLoad(t, []string{writeZone(t, "example.com.zone", wildZone)}, Options{}) + // sub.example.com is a cut, so the closest encloser of x.sub.example.com + // is a delegation point: the child server owns the name, wildcard or not. + refer(t, s, "x.sub.example.com", dns.TypeA) +} + +// chase follows a CNAME chain inside the store, which is what the handler does +// after a local CNAME answer: Lookup stops at the CNAME. maxHops is the loop +// guard, so a cycle is reported instead of followed. +func chase(store *Store, name string, qtype uint16, maxHops int) ([]dns.RR, error) { + var out []dns.RR + current, seen := dns.Fqdn(name), map[string]bool{} + for hop := 0; hop < maxHops; hop++ { + res := store.Lookup(q(current, qtype)) + if !res.Found { + return out, fmt.Errorf("%s/%s: no local answer", current, dns.TypeToString[qtype]) + } + if len(res.Records) == 0 { + return out, nil + } + rr := res.Records[0] + out = append(out, rr) + if rr.Header().Rrtype != dns.TypeCNAME { + return out, nil + } + target := rr.(*dns.CNAME).Target + if seen[target] { + return out, fmt.Errorf("CNAME loop back to %s", target) + } + seen[target] = true + current = target + } + return out, fmt.Errorf("CNAME chain longer than %d hops", maxHops) +} + +func TestCNAMEAnswerStopsAtTheCNAME(t *testing.T) { + s := loadBase(t) + + // A CNAME answers any other type, with the CNAME alone: the handler + // re-queries the target, which keeps the wildcard and delegation rules in + // one place. + m := ask(t, s, "www.example.com", dns.TypeA) + checkMsg(t, m, want{answer: []string{rrText(t, "www.example.com. 300 IN CNAME host.example.com.")}}) + if res := s.Lookup(q("www.example.com", dns.TypeA)); res.Type != dns.TypeCNAME { + t.Errorf("Lookup(www/A).Type = %d, want CNAME", res.Type) + } + + // Asking for the CNAME itself gives the same RRset. + m = ask(t, s, "www.example.com", dns.TypeCNAME) + checkMsg(t, m, want{answer: []string{rrText(t, "www.example.com. 300 IN CNAME host.example.com.")}}) + + // Chasing the chain inside the zone reaches the target address. + got, err := chase(s, "www.example.com", dns.TypeA, 8) + if err != nil { + t.Fatalf("chase(www/A): %v", err) + } + if want := []string{ + rrText(t, "www.example.com. 300 IN CNAME host.example.com."), + rrText(t, "host.example.com. 300 IN A 192.0.2.1"), + }; !slices.Equal(rrs(got), want) { + t.Errorf("chase(www/A) = %q, want %q", rrs(got), want) + } + + // A target outside every local zone is not resolved here: that is the + // caller's signal to go upstream. + if _, err := chase(s, "off.example.com", dns.TypeA, 8); err == nil { + t.Error("chase(off/A): expected an error for a target outside the local zones") + } +} + +func TestCNAMELoopIsNotFollowed(t *testing.T) { + s := loadBase(t) + + // Every lookup stops at the CNAME, so a cycle costs one map lookup and + // never recurses. want spells the record out in full: dns.NewRR needs the + // TTL and class columns, so a bare "name CNAME target" will not parse. + for _, tc := range []struct { + name string + rdata string + }{ + {name: "alias.example.com", rdata: "loop-a.example.com."}, + {name: "self.example.com", rdata: "self.example.com."}, + } { + m := ask(t, s, tc.name, dns.TypeA) + want := rrText(t, tc.name+". 300 IN CNAME "+tc.rdata) + checkMsg(t, m, want2(want)) + if _, err := chase(s, tc.name, dns.TypeA, 8); err == nil { + t.Errorf("chase(%s/A): expected a loop error", tc.name) + } + } +} + +// want2 wraps a single answer RR into a want. +func want2(answer string) want { return want{answer: []string{answer}} } + +func TestClassIsCarriedButNotIndexed(t *testing.T) { + s := mustLoad(t, []string{writeZone(t, "example.com.zone", classZone)}, Options{}) + + // A CH question is answered from the CH record, and the record keeps its + // class in the reply. + res := s.Lookup(q("weird.example.com", dns.TypeA)) + if !res.Found || len(res.Records) != 1 { + t.Fatalf("Lookup(weird/A) = %+v, want the CH address", res) + } + if got := res.Records[0].Header().Class; got != dns.ClassCHAOS { + t.Errorf("answer class = %s, want CH", dns.ClassToString[got]) + } + m := ask(t, s, "weird.example.com", dns.TypeA) + if m.Question[0].Qclass != dns.ClassINET { + t.Errorf("question class = %s, want IN", dns.ClassToString[m.Question[0].Qclass]) + } + + // The store keys on name and type only, so the class neither selects nor + // hides an RRset: an IN question for the same name gets the same record. + in := s.Lookup(q("weird.example.com", dns.TypeA)) + if !in.Found { + t.Error("Lookup with an IN question returned nothing for a name that has a record") + } + // ... and a CH question for an IN name is answered too. + chaos := dns.Question{Name: "host.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassCHAOS} + if res := s.Lookup(chaos); !res.Found || res.Records[0].Header().Class != dns.ClassINET { + t.Errorf("Lookup(host/A, CH) = %v, want the IN address", rrs(res.Records)) + } +} + +func TestLookupANY(t *testing.T) { + s := loadBase(t) + + // ANY collects the well-known types at the name, in a stable order. + m := ask(t, s, "host.example.com", dns.TypeANY) + checkMsg(t, m, want{answer: []string{ + rrText(t, "host.example.com. 300 IN A 192.0.2.1"), + rrText(t, "host.example.com. 300 IN AAAA 2001:db8::1"), + }}) + + // The apex holds no queryable data but its NS set. + m = ask(t, s, "example.com", dns.TypeANY) + checkMsg(t, m, want{answer: []string{rrText(t, "example.com. 3600 IN NS ns1.example.com.")}}) + + // A name that does not exist is still NXDOMAIN. + m = ask(t, s, "gone.example.com", dns.TypeANY) + checkMsg(t, m, want{rcode: dns.RcodeNameError, authority: []string{rrText(t, soaText)}}) + + // A wildcard covers an ANY query too, with the queried owner name. + w := mustLoad(t, []string{writeZone(t, "example.com.zone", wildZone)}, Options{}) + m = ask(t, w, "x.example.com", dns.TypeANY) + checkMsg(t, m, want2(rrText(t, "x.example.com. 300 IN A 192.0.2.99"))) +} + +func TestLookupPicksTheZoneThatContainsTheName(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "example.com.zone") + if err := os.WriteFile(p, []byte(baseZone), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + p = filepath.Join(dir, "other.test.zone") + if err := os.WriteFile(p, []byte(otherZone), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + s := mustLoad(t, []string{dir}, Options{}) + if got := s.Zones(); !slices.Equal(got, []string{"example.com.", "other.test."}) { + t.Fatalf("Zones() = %q, want both apexes sorted", got) + } + + m := ask(t, s, "host.other.test", dns.TypeA) + checkMsg(t, m, want2(rrText(t, "host.other.test. 300 IN A 192.0.2.99"))) + if res := s.Lookup(q("host.other.test", dns.TypeA)); res.Zone != "other.test." { + t.Errorf("Zone = %q, want other.test.", res.Zone) + } + + // A name outside every zone is left to the upstream path. + if m, ok := reply(s, q("host.nosuch.test", dns.TypeA)); ok { + t.Errorf("host.nosuch.test: expected a pass-through, got %q", rrs(m.Answer)) + } + if res := s.Lookup(q("host.nosuch.test", dns.TypeA)); res.Zone != "" || res.Found { + t.Errorf("Lookup outside every zone = %+v, want an empty Result", res) + } +} + +func TestStoreAccessorsAndNilStore(t *testing.T) { + var nilStore *Store + if nilStore.Count() != 0 || nilStore.Zones() != nil || nilStore.Files() != nil || + !nilStore.BuiltAt().IsZero() || nilStore.Records("example.com.") != nil { + t.Error("a nil store must answer every accessor with the zero value") + } + if res := nilStore.Lookup(q("host.example.com", dns.TypeA)); res.Found || res.Zone != "" { + t.Errorf("Lookup on a nil store = %+v, want the zero Result", res) + } + + s := loadBase(t) + if h := NewHolder(nil); h.Get() != nil { + t.Error("NewHolder(nil).Get() != nil") + } + h := NewHolder(s) + if h.Get() != s { + t.Error("NewHolder(store).Get() did not return the store") + } + // Records() lists the zone as sorted zone-file text, wildcards included. + want := []string{ + rrText(t, "alias.example.com. 300 IN CNAME loop-a.example.com."), + rrText(t, "example.com. 3600 IN NS ns1.example.com."), + rrText(t, "host.example.com. 300 IN A 192.0.2.1"), + rrText(t, "host.example.com. 300 IN AAAA 2001:db8::1"), + rrText(t, "loop-a.example.com. 300 IN CNAME alias.example.com."), + rrText(t, "ns1.example.com. 3600 IN A 192.0.2.53"), + rrText(t, "ns1.sub.example.com. 3600 IN A 192.0.2.60"), + rrText(t, "off.example.com. 300 IN CNAME elsewhere.test."), + rrText(t, "self.example.com. 300 IN CNAME self.example.com."), + rrText(t, "sub.example.com. 3600 IN NS ns1.sub.example.com."), + rrText(t, "text.example.com. 300 IN TXT \"a node with no address\""), + rrText(t, "www.example.com. 300 IN CNAME host.example.com."), + } + if got := s.Records("example.com"); !slices.Equal(got, want) { + t.Errorf("Records(example.com) mismatch\n got: %q\nwant: %q", got, want) + } + if got := s.Records("nosuch.example.com."); got != nil { + t.Errorf("Records(unknown zone) = %q, want nil", got) + } +} + +func TestLoadDirectoryGlobAndOptions(t *testing.T) { + dir := t.TempDir() + for name, text := range map[string]string{ + "one.zone": "$ORIGIN one.test.\n@ 3600 IN SOA ns.one.test. hostmaster.one.test. 1 7200 3600 1209600 300\n", + "two.zone": "$ORIGIN two.test.\n@ 3600 IN SOA ns.two.test. hostmaster.two.test. 1 7200 3600 1209600 300\n", + "ignored.txt": "not a zone file\n", + "three.zone.tmp": "", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + // A directory contributes its *.zone files, in name order. + s := mustLoad(t, []string{dir}, Options{}) + if got := s.Zones(); !slices.Equal(got, []string{"one.test.", "two.test."}) { + t.Errorf("Zones() = %q, want one.test. and two.test.", got) + } + if got, want := len(s.Files()), 2; got != want { + t.Errorf("loaded %d files, want %d: %q", got, want, s.Files()) + } + + // MaxFiles bounds the list, a glob expands, and a repeat is not counted + // twice. + one := mustLoad(t, []string{dir, dir, filepath.Join(dir, "*.zone")}, Options{MaxFiles: 1}) + if got, want := len(one.Files()), 1; got != want { + t.Errorf("MaxFiles=1 loaded %d files, want 1: %q", got, one.Files()) + } +} + +func TestLoadOriginDerivation(t *testing.T) { + // Without $ORIGIN the apex comes from the file name. The owner is a word + // rather than "a" because a leading token naming an RR type reads as an + // omitted owner (" A 192.0.2.1"), which no parser accepts on the first + // line of a file -- it has no previous owner to inherit. + p := writeZone(t, "fallback.zone", "host 300 IN A 192.0.2.1\n") + s := mustLoad(t, []string{p}, Options{}) + if got := s.Zones(); !slices.Equal(got, []string{"fallback."}) { + t.Errorf("Zones() = %q, want the origin derived from the file name", got) + } + if res := s.Lookup(q("host.fallback.", dns.TypeA)); !res.Found { + t.Errorf("host.fallback. not found, want the record qualified against the derived origin") + } + + // Options.Origins overrides it per file. + s = mustLoad(t, []string{p}, Options{Origins: map[string]string{p: "override.test."}}) + if got := s.Zones(); !slices.Equal(got, []string{"override.test."}) { + t.Errorf("Zones() = %q, want the overridden origin", got) + } + if res := s.Lookup(q("host.override.test.", dns.TypeA)); !res.Found { + t.Error("host.override.test. not found, want the record qualified against the override") + } +} + +func TestLoadErrors(t *testing.T) { + dir := t.TempDir() + if _, err := Load([]string{filepath.Join(dir, "missing.zone")}, Options{}); err == nil { + t.Error("Load of a missing file: expected an error") + } + bad := writeZone(t, "bad.zone", "this is not a record\n") + _, err := Load([]string{bad}, Options{}) + if err == nil { + t.Fatal("Load of a malformed file: expected an error") + } + if !strings.HasPrefix(err.Error(), "localzone:") { + t.Errorf("error %q is not prefixed with the package name", err) + } + if _, err := Load([]string{filepath.Join(dir, "[bad")}, Options{}); err == nil { + t.Error("Load with a malformed glob: expected an error") + } +} + +func TestLoadMergesFilesForOneOrigin(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "01.zone") + second := filepath.Join(dir, "02.zone") + if err := os.WriteFile(first, []byte(`$ORIGIN example.com. +@ 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300 +@ 3600 IN NS ns1.example.com. +one 300 IN A 192.0.2.1 +`), 0o600); err != nil { + t.Fatalf("write %s: %v", first, err) + } + if err := os.WriteFile(second, []byte(`$ORIGIN example.com. +two 300 IN A 192.0.2.2 +`), 0o600); err != nil { + t.Fatalf("write %s: %v", second, err) + } + s := mustLoad(t, []string{dir}, Options{}) + if got := s.Zones(); !slices.Equal(got, []string{"example.com."}) { + t.Errorf("Zones() = %q, want a single merged zone", got) + } + // Four records were parsed, but the SOA is zone metadata rather than a + // queryable record, so only the NS and the two A records are counted. + if got, want := s.Count(), 3; got != want { + t.Errorf("Count() = %d, want %d across both files", got, want) + } + m := ask(t, s, "one.example.com", dns.TypeA) + checkMsg(t, m, want2(rrText(t, "one.example.com. 300 IN A 192.0.2.1"))) + m = ask(t, s, "two.example.com", dns.TypeA) + checkMsg(t, m, want2(rrText(t, "two.example.com. 300 IN A 192.0.2.2"))) +} + +func TestHolderSwapIsRaceFree(t *testing.T) { + first := loadBase(t) + second := mustLoad(t, []string{writeZone(t, "other.test.zone", otherZone)}, Options{}) + h := NewHolder(first) + + stop := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + // One snapshot load per lookup, and the store it returns stays + // usable and immutable after it has been replaced. + store := h.Get() + if store == nil { + t.Error("holder returned a nil store") + return + } + // Either store may be the published one, and each has to + // answer for the data it was compiled from. + res := store.Lookup(q("host.example.com", dns.TypeA)) + if !res.Found { + res = store.Lookup(q("host.other.test.", dns.TypeA)) + } + if !res.Found { + t.Error("a published store lost its records") + return + } + } + }() + } + for i := 0; i < 500; i++ { + if i%2 == 0 { + h.Set(first) + } else { + h.Set(second) + } + } + close(stop) + wg.Wait() +} + +// benchStore compiles a zone with hosts A records for the lookup benchmarks. +func benchStore(b *testing.B, hosts int) *Store { + b.Helper() + var sb strings.Builder + sb.WriteString(baseZone) + for i := 0; i < hosts; i++ { + fmt.Fprintf(&sb, "h%d\t300\tIN A\t192.0.%d.%d\n", i%256, i/256, i%256+1) + } + p := filepath.Join(b.TempDir(), "example.com.zone") + if err := os.WriteFile(p, []byte(sb.String()), 0o600); err != nil { + b.Fatalf("write %s: %v", p, err) + } + s, err := Load([]string{p}, Options{}) + if err != nil { + b.Fatalf("Load: %v", err) + } + return s +} + +func benchLookup(b *testing.B, s *Store, questions []dns.Question, found bool) { + b.Helper() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + res := s.Lookup(questions[i%len(questions)]) + if res.Found != found { + b.Fatalf("%v: Found = %v, want %v", questions[i%len(questions)], res.Found, found) + } + } +} + +func BenchmarkLookupExact(b *testing.B) { + s := benchStore(b, 2000) + benchLookup(b, s, []dns.Question{ + q("h100.example.com", dns.TypeA), + q("host.example.com", dns.TypeA), + q("h1500.example.com", dns.TypeA), + }, true) +} + +func BenchmarkLookupNXDOMAIN(b *testing.B) { + s := benchStore(b, 2000) + benchLookup(b, s, []dns.Question{ + q("nothere.example.com", dns.TypeA), + q("deep.nothere.example.com", dns.TypeA), + }, false) +} + +func BenchmarkLookupDelegated(b *testing.B) { + s := benchStore(b, 2000) + benchLookup(b, s, []dns.Question{ + q("host.sub.example.com", dns.TypeA), + q("sub.example.com", dns.TypeNS), + }, false) +} + +func BenchmarkLookupWildcard(b *testing.B) { + dir := b.TempDir() + p := filepath.Join(dir, "example.com.zone") + if err := os.WriteFile(p, []byte(wildZone), 0o600); err != nil { + b.Fatalf("write %s: %v", p, err) + } + s, err := Load([]string{p}, Options{}) + if err != nil { + b.Fatalf("Load: %v", err) + } + benchLookup(b, s, []dns.Question{ + q("x.example.com", dns.TypeA), + q("x.y.example.com", dns.TypeA), + }, true) +} + +func BenchmarkLoad(b *testing.B) { + var sb strings.Builder + sb.WriteString(baseZone) + for i := 0; i < 2000; i++ { + fmt.Fprintf(&sb, "h%d\t300\tIN A\t192.0.%d.%d\n", i%256, i/256, i%256+1) + } + p := filepath.Join(b.TempDir(), "example.com.zone") + if err := os.WriteFile(p, []byte(sb.String()), 0o600); err != nil { + b.Fatalf("write %s: %v", p, err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Load([]string{p}, Options{}); err != nil { + b.Fatalf("Load: %v", err) + } + } +} diff --git a/internal/localzone/store.go b/internal/localzone/store.go index 137dd20..1b75a1a 100644 --- a/internal/localzone/store.go +++ b/internal/localzone/store.go @@ -123,9 +123,9 @@ func add(z *zone, rec zonefile.Record) bool { if hdr.Rrtype == dns.TypeNS && !isWild { if key == z.origin { z.ns[hdr.Rrtype] = append(z.ns[hdr.Rrtype], rec.RR) - return true + } else { + z.delegations[key] = true } - z.delegations[key] = true } if hdr.Ttl < z.minTTL { z.minTTL = hdr.Ttl @@ -246,8 +246,17 @@ func (s *Store) Lookup(q dns.Question) Result { if qtype == dns.TypeANY { return s.any(best, name) } - // A delegation means "ask them, not me". - if best.delegations[name] { + // A delegation means "ask them, not me": the parent holds no data at or + // below the cut, so it must neither answer nor claim NXDOMAIN. The NS + // RRset at the cut is the one exception -- only the parent has it, and a + // resolver needs it to learn the delegation (RFC 1034 §4.3.3). + for d := name; d != ""; d = parentOf(d) { + if !best.delegations[d] { + continue + } + if d == name && qtype == dns.TypeNS { + break + } return Result{Found: false, Delegated: true, Zone: best.origin, Authority: best.soa, TTL: best.minTTL} } if rrs, ok := best.exact[name+"|"+dns.TypeToString[qtype]]; ok { @@ -256,7 +265,8 @@ func (s *Store) Lookup(q dns.Question) Result { // Wildcard: "*.zone" covers one label; RFC 1034 §4.3.3 wants the closest // enclosing wildcard, so try progressively shorter parents. if rrs, ok := closestWildcard(best, name, qtype); ok { - return s.result(best, name, qtype, rrs) + // A wildcard answer is labelled with the name that was asked for. + return s.result(best, name, qtype, synthesize(rrs, name)) } // A CNAME at the name answers any other type. if qtype != dns.TypeCNAME { @@ -309,7 +319,7 @@ func (s *Store) any(z *zone, name string) Result { } if len(out) == 0 { if rrs, ok := closestWildcardAny(z, name); ok { - out = append(out, rrs...) + out = append(out, synthesize(rrs, name)...) } } if len(out) == 0 { @@ -319,13 +329,20 @@ func (s *Store) any(z *zone, name string) Result { } func closestWildcard(z *zone, name string, qtype uint16) ([]dns.RR, bool) { + // A wildcard only fills in for a name that has no node of its own. + if len(z.wild) == 0 || nameExists(z, name) { + return nil, false + } for parent := parentOf(name); parent != ""; parent = parentOf(parent) { if m, ok := z.wild[parent]; ok { if rrs, ok := m[qtype]; ok { return rrs, true } } - if z.delegations[parent] { + // A real node (or a delegation) is the closest encloser, and only a + // wildcard directly below it can answer: a wildcard never reaches + // past a name that exists. + if z.delegations[parent] || nameExists(z, parent) { return nil, false } } @@ -333,6 +350,9 @@ func closestWildcard(z *zone, name string, qtype uint16) ([]dns.RR, bool) { } func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) { + if len(z.wild) == 0 || nameExists(z, name) { + return nil, false + } for parent := parentOf(name); parent != ""; parent = parentOf(parent) { if m, ok := z.wild[parent]; ok { var out []dns.RR @@ -343,6 +363,9 @@ func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) { return out, true } } + if z.delegations[parent] || nameExists(z, parent) { + return nil, false + } } return nil, false } @@ -355,7 +378,28 @@ func parentOf(name string) string { return name[i+1:] } +// synthesize copies rrs with the owner name replaced by qname, because a +// wildcard answer is labelled with the name that was asked for. The store is +// shared read-only, so the records are copied instead of rewritten in place. +func synthesize(rrs []dns.RR, qname string) []dns.RR { + out := make([]dns.RR, 0, len(rrs)) + for _, rr := range rrs { + cp := dns.Copy(rr) + if cp == nil { + continue + } + cp.Header().Name = qname + out = append(out, cp) + } + return out +} + func nameExists(z *zone, name string) bool { + // The apex always exists: it carries the SOA and the NS set, and neither + // is indexed as a queryable record. + if name == z.origin { + return true + } prefix := name + "|" for k := range z.exact { if strings.HasPrefix(k, prefix) { diff --git a/internal/zonefile/zonefile.go b/internal/zonefile/zonefile.go index d236e9a..fd60915 100644 --- a/internal/zonefile/zonefile.go +++ b/internal/zonefile/zonefile.go @@ -118,11 +118,8 @@ func Parse(r io.Reader, opt Options) (*Zone, error) { if !hasTTL && z.DefaultTTL > 0 { rr.Header().Ttl = z.DefaultTTL } - // RFC 1035 §5.1: a domain name in the RDATA section that is written - // without a trailing dot is relative to the zone origin, not to the - // root. Left to the library it becomes "host.", which points a CNAME - // at the root instead of at the zone. - qualifyRdata(rr, z.Origin) + // assemble has already qualified the RDATA names that were written + // relative to the origin, so the record holds no relative name. rec := Record{ Name: strings.ToLower(ensureDot(rr.Header().Name)), TTL: rr.Header().Ttl, @@ -229,6 +226,13 @@ func (z *Zone) assemble(line, lastOwner string) (string, bool, error) { if len(rest) == 0 { return "", false, fmt.Errorf("record %q has no type", head) } + // RFC 1035 §5.1: a domain name in the RDATA section that is written + // without a trailing dot is relative to the zone origin, not to the + // root. It has to be qualified here, while it is still a token: the RR + // library resolves RDATA names against the root, so once dns.NewRR has + // run, "host" and "host." are indistinguishable and the origin would be + // lost. + qualifyRdata(rest[1:], rest[0], z.Origin) out = append(out, rest...) return strings.Join(out, " "), hasTTL, nil } @@ -290,45 +294,35 @@ func cloneSOA(in *dns.SOA) *dns.SOA { return &out } -// qualifyRdata appends the origin to the domain names in a record's RDATA -// that were written without a trailing dot. RFC 1035 §5.1 makes those names -// relative to the zone origin, so "www CNAME host" in example.com. means +// rdataNameIndex maps an RR type to the positions of the domain names in its +// presentation-format RDATA, counted from the first token after the type. The +// position of a name follows from the type's field layout, so a relative name +// can be qualified while the line is still a token stream. A type that carries +// no name in its RDATA is absent, and so is a type assemble does not +// recognise, since such a record never reaches this point. +var rdataNameIndex = map[string][]int{ + "CNAME": {0}, + "HTTPS": {1}, // SvcPriority, TargetName, SvcParams... + "MX": {1}, // Preference, Exchange + "NAPTR": {5}, // Order, Preference, Flags, Service, Regexp, Replacement + "NS": {0}, + "PTR": {0}, + "SOA": {0, 1}, // MName, RName + "SRV": {3}, // Priority, Weight, Port, Target + "SVCB": {1}, +} + +// qualifyRdata appends the origin to the domain names in a record's RDATA that +// were written without a trailing dot: "www CNAME host" in example.com. means // host.example.com. and not host. A name that already ends in a dot is // absolute and left alone, which keeps the RPZ targets "." and "rpz-drop." -// and the null MX/SRV targets intact. -func qualifyRdata(rr dns.RR, origin string) { - switch v := rr.(type) { - case *dns.CNAME: - v.Target = relative(v.Target, origin) - case *dns.DNAME: - v.Target = relative(v.Target, origin) - case *dns.NS: - v.Ns = relative(v.Ns, origin) - case *dns.PTR: - v.Ptr = relative(v.Ptr, origin) - case *dns.MX: - v.Mx = relative(v.Mx, origin) - case *dns.SRV: - v.Target = relative(v.Target, origin) - case *dns.SOA: - v.Ns = relative(v.Ns, origin) - v.Mbox = relative(v.Mbox, origin) - case *dns.NAPTR: - v.Replacement = relative(v.Replacement, origin) - case *dns.RP: - v.Mbox = relative(v.Mbox, origin) - v.Txt = relative(v.Txt, origin) - case *dns.MINFO: - v.Rmail = relative(v.Rmail, origin) - v.Email = relative(v.Email, origin) - case *dns.MB: - v.Mb = relative(v.Mb, origin) - case *dns.MG: - v.Mg = relative(v.Mg, origin) - case *dns.MR: - v.Mr = relative(v.Mr, origin) - case *dns.KX: - v.Exchanger = relative(v.Exchanger, origin) +// and the null MX/SRV targets intact. rdata is the token slice after the type +// and is qualified in place. +func qualifyRdata(rdata []string, rrtype, origin string) { + for _, i := range rdataNameIndex[strings.ToUpper(rrtype)] { + if i < len(rdata) { + rdata[i] = relative(rdata[i], origin) + } } } diff --git a/internal/zonefile/zonefile_test.go b/internal/zonefile/zonefile_test.go index 407278f..e410141 100644 --- a/internal/zonefile/zonefile_test.go +++ b/internal/zonefile/zonefile_test.go @@ -609,6 +609,62 @@ func TestParseOriginDirective(t *testing.T) { }) } +// A domain name in the RDATA section that is written without a trailing dot is +// relative to the zone origin (RFC 1035 §5.1). The qualification has to happen +// on the token stream, before dns.NewRR: the library resolves RDATA names +// against the root, so afterwards "host" and "host." are indistinguishable and +// a relative CNAME target would silently point at the root instead. +func TestParseRelativeRdata(t *testing.T) { + // The owners are deliberately not RR type names: a leading token that names + // a type reads as an omitted owner, as assemble documents. + z := mustParse(t, `$ORIGIN example.com. +cn 300 IN CNAME host +ns1sub 300 IN NS ns1.sub +ptral 300 IN PTR host +mail 300 IN MX 10 mailhost +srvone 300 IN SRV 0 1 5060 sip +napone 300 IN NAPTR 100 50 "s" "http+I2R" "" _http._tcp +svcone 300 IN SVCB 1 _sip._tcp +zone1 300 IN SOA ns1 hostmaster 1 3600 600 604800 300 +abs 300 IN CNAME host.other.test. +nullmx 300 IN MX 10 . +txtone 300 IN TXT "host" +`, Options{Origin: "example.com."}) + checkSummary(t, z, []string{ + `2 cn.example.com. 300 IN CNAME host.example.com.`, + `3 ns1sub.example.com. 300 IN NS ns1.sub.example.com.`, + `4 ptral.example.com. 300 IN PTR host.example.com.`, + `5 mail.example.com. 300 IN MX 10 mailhost.example.com.`, + `6 srvone.example.com. 300 IN SRV 0 1 5060 sip.example.com.`, + `7 napone.example.com. 300 IN NAPTR 100 50 "s" "http+I2R" "" _http._tcp.example.com.`, + `8 svcone.example.com. 300 IN SVCB 1 _sip._tcp.example.com.`, + `9 zone1.example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300`, + `10 abs.example.com. 300 IN CNAME host.other.test.`, + `11 nullmx.example.com. 300 IN MX 10 .`, + `12 txtone.example.com. 300 IN TXT "host"`, + }) +} + +// Each line is assembled on its own and fold continues a statement only while +// parentheses are open, so a record never swallows the line after it -- whatever +// its token count is. +func TestParseRecordsDoNotRunTogether(t *testing.T) { + z := mustParse(t, `$ORIGIN example.com. +ns1 A 192.0.2.53 +note TXT "hello world" +@ IN SOA ns1 hostmaster 1 3600 600 604800 300 +mail MX 10 mxhost +`, Options{}) + if got, want := summaryNoTTL(t, z), []string{ + `2 ns1.example.com. IN A 192.0.2.53`, + `3 note.example.com. IN TXT "hello world"`, + `4 example.com. IN SOA ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300`, + `5 mail.example.com. IN MX 10 mxhost.example.com.`, + }; !slices.Equal(got, want) { + t.Errorf("records mismatch\n got: %q\nwant: %q", got, want) + } +} + func TestParseClassHandling(t *testing.T) { z := mustParse(t, `one IN A 192.0.2.1 two in A 192.0.2.2