Files
developer a5e54c09df
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 20s
ci / Binary with the console embedded (push) Successful in 17s
ci / Container image (push) Failing after 8s
Qualify relative RDATA names in the zone-file parser
A domain name in the RDATA section written without a trailing dot is
relative to the zone origin (RFC 1035 §5.1), so "www CNAME host" in
example.com. means host.example.com. The qualifier ran on the record
after dns.NewRR had already resolved every RDATA name against the root,
which turns "host" into "host." and makes the two indistinguishable --
relative() saw the trailing dot and left it alone. Every relative CNAME,
NS, MX, PTR, SRV, NAPTR, SOA and SVCB target therefore pointed at the
DNS root, which showed up as a local zone answering a CNAME with
"host." and the alias target holding no local answer. Qualification now
happens in assemble on the token stream, before the record is built,
using a table of the RDATA positions that hold a name per type; names
that already end in a dot stay absolute, so the RPZ targets "." and
"rpz-drop." and the null MX targets are unaffected.

Fix three wrong expectations in the localzone tests while here. The loop
fixture was built with dns.NewRR on "alias.example.com", which cannot
parse because the RR library wants a TTL and class column, so the
expected record now spells them out. The origin-derivation fixture
used "a" as an owner name, and a leading token that names an RR type
reads as an omitted owner, which has no previous owner to inherit on
the first line of a file; the owner is now a word. The merge test
counted the SOA, but it is zone metadata rather than a queryable
record, so two files contribute three records. The holder race test also
looked up a name from the base zone while the holder was serving a store
compiled from other.test., which no answer could satisfy; it now
accepts either store's data.
2026-09-27 14:10:25 +00:00

901 lines
32 KiB
Go

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