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