package localzone import ( "os" "path/filepath" "strings" "testing" "github.com/miekg/dns" ) // rendered renders a zone and loads it the way the resolver does, so a test // asserts on what the query path would actually serve. func rendered(t *testing.T, origin string, def ZoneDefaults, recs []Record) (*Store, string) { t.Helper() text, err := RenderZone(origin, def, recs, 7) if err != nil { t.Fatalf("render %s: %v", origin, err) } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "z.zone"), []byte(text), 0o600); err != nil { t.Fatal(err) } store, err := Load([]string{dir}, Options{}) if err != nil { t.Fatalf("load rendered zone: %v\n%s", err, text) } return store, text } func ns(origin string) Record { return Record{Name: origin, Type: "NS", Value: "ns1." + origin} } func TestRenderedZoneIsAParsableZoneFile(t *testing.T) { store, text := rendered(t, "example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, }) for _, want := range []string{"$ORIGIN example.com.", "$TTL 5m", "IN SOA", "IN NS", "IN A", "10.0.0.1", "Kubernetes DNSZone"} { if !strings.Contains(text, want) { t.Errorf("zone file is missing %q:\n%s", want, text) } } zone := store.Snapshot("example.com.", "") if zone == nil || zone.Serial != 7 { t.Fatalf("zone = %+v, want the serial that was asked for", zone) } // The same input renders the same bytes: replicas must agree. again, err := RenderZone("example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, }, 7) if err != nil || again != text { t.Fatalf("rendering is not deterministic (%v)", err) } } func TestWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) { store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"}, {Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"}, }) zone := store.Snapshot("example.com.", "") byKey := map[string]Record{} for _, rec := range zone.Records { byKey[rec.Key()] = rec } wild, ok := byKey["*.example.com.|A"] if !ok || !wild.Wildcard || wild.Value != "10.0.0.9" { t.Fatalf("the wildcard record = %+v (%v)", wild, ok) } if cname := byKey["alias.example.com.|CNAME"]; cname.Value != "target.example.com." { t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value) } if res := store.Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA}); !res.Found || len(res.Records) != 1 { t.Errorf("wildcard lookup = %+v, want an answer", res) } } func TestEveryEditableTypeSurvivesTheFile(t *testing.T) { samples := map[string]string{ "A": "10.0.0.1", "AAAA": "2001:db8::1", "CAA": `0 issue "letsencrypt.org"`, "CNAME": "target.example.com.", "DHCID": "AAIBY2/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=", "HINFO": `"cpu" "os"`, "HTTPS": "1 . alpn=h2", "LOC": "42 21 54 N 71 06 18 W -24m 30m 0m 10m", "MX": "10 mail.example.com.", "NAPTR": `100 50 "S" "SIP+D2U" "!^.*$!sip:info@example.com!" _sip._udp.example.com.`, "NS": "ns1.example.com.", "PTR": "host.example.com.", "SRV": "0 1 443 target.example.com.", "SPF": `"v=spf1 -all"`, "SSHFP": "1 1 aa11223344556666778899aabbccddeeff00112233445566778899aabbccddeeff", "SVCB": "1 target.example.com. alpn=h2", "TXT": `"hello" "world"`, "URI": `10 1 "https://example.com/"`, } for _, rrtype := range EditableTypes() { sample, ok := samples[rrtype] if !ok { t.Errorf("editable type %s has no sample in this test; the renderer is untested for it", rrtype) continue } text, err := RenderZone("example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample}, }, 1) if err != nil { t.Errorf("%s: render: %v", rrtype, err) continue } zf, err := ParseZoneFile("example.com", text) if err != nil { t.Errorf("%s: the rendered file does not parse: %v", rrtype, err) continue } found := false for _, r := range zf.Records { if r.Name == "rec.example.com." && r.Type == rrtype && r.TTL == 300 { found = true } } if !found { t.Errorf("%s: the record did not survive the file: %+v", rrtype, zf.Records) } } } func TestRelativeOwnersAreQualified(t *testing.T) { store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"}, {Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"}, }) got := map[string]string{} for _, rec := range store.Snapshot("example.com.", "").Records { got[rec.Name] = rec.Value } if got["www.example.com."] != "10.0.0.1" || got["*.cdn.example.com."] != "10.0.0.2" { t.Errorf("owners = %v", got) } if res := store.Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found { t.Error("the wildcard does not answer under its qualified name") } if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{ns("example.com."), {Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}}, 1); err == nil { t.Error("an absolute name outside the zone was accepted") } } // A zone whose SOA names a server that is not in its own NS set hands out a // referral no resolver can follow. func TestSOAPrimaryFollowsTheApexNSSet(t *testing.T) { store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ns("example.com.")}) if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns1.example.com. ") { t.Fatalf("soa = %q, want ns1 as the primary", soa) } store, _ = rendered(t, "example.com", ZoneDefaults{Soa: "ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300"}, []Record{{Name: "example.com.", Type: "NS", Value: "ns2.example.com."}}) if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns2.example.com. ") { t.Errorf("soa = %q: a seed SOA must not name a server outside the NS set", soa) } } func TestRenderRefusesWhatItCannotServe(t *testing.T) { for name, tc := range map[string]struct { origin string recs []Record }{ "no server list": {"example.com", []Record{{Name: "www.example.com.", Type: "A", Value: "10.0.0.1"}}}, "bad apex": {"com", []Record{ns("com.")}}, "root": {".", nil}, "duplicate": {"example.com", []Record{ns("example.com."), ns("example.com.")}}, "soa as record": {"example.com", []Record{ns("example.com."), {Name: "example.com.", Type: "SOA", Value: "a. b. 1 2 3 4 5"}}}, "below a cut": {"example.com", []Record{ ns("example.com."), {Name: "sub.example.com.", Type: "NS", Value: "ns.sub.example.com."}, {Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"}, }}, } { if _, err := RenderZone(tc.origin, ZoneDefaults{}, tc.recs, 1); err == nil { t.Errorf("%s: accepted", name) } } // The rule follows the cut, not the record. if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{ ns("example.com."), {Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"}, }, 1); err != nil { t.Errorf("a record under a name that is not delegated was refused: %v", err) } } func TestNormalizeOriginAndName(t *testing.T) { if got, err := NormalizeOrigin(" Example.COM "); err != nil || got != "example.com." { t.Errorf("NormalizeOrigin = %q, %v", got, err) } if got, err := NormalizeName("*.WWW.Example.com"); err != nil || got != "*.www.example.com." { t.Errorf("NormalizeName = %q, %v", got, err) } if _, err := NormalizeName("."); err == nil { t.Error("the root was accepted as an owner name") } if _, err := NormalizeOrigin("*."); err == nil { t.Error("a wildcard was accepted as a zone apex") } } func TestZoneFileIsHumanReadable(t *testing.T) { f := &fileState{ origin: "example.com.", ttl: 300, soa: mustSOA(t, "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300"), recs: []Record{ {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, {Name: "example.com.", Type: "NS", TTL: 300, Value: "ns1.example.com."}, }, } text := f.render() if !strings.Contains(text, "@") || !strings.Contains(text, "IN SOA") { t.Errorf("the apex SOA is not written with @:\n%s", text) } // One record per line: this project's master-file parser takes a bare owner // line as a record with no type. for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "$") { continue } if !strings.Contains(line, " IN ") { t.Errorf("record line without an owner, TTL, class and type: %q\n%s", line, text) } } parsed, err := parseFileState("example.com.", text) if err != nil { t.Fatalf("rendered file does not parse: %v\n%s", err, text) } if err := sameRecords(parsed.recs, sortCopy(f.recs)); err != nil { t.Errorf("rendered file differs from the records: %v\n%s", err, text) } } func TestParseZoneFile(t *testing.T) { zf, err := ParseZoneFile("Home.Example.com", "$TTL 120\n@ IN SOA ns1 host 5 3600 600 604800 120\n@ IN NS ns1\nns1 IN A 10.0.0.1\nweb 30 IN A 10.0.0.2\n") if err != nil { t.Fatal(err) } if zf.TTL != 120 || !strings.HasPrefix(zf.Soa, "ns1.home.example.com. host.home.example.com. 5 ") || len(zf.Records) != 3 { t.Fatalf("zone file = %+v", zf) } for _, r := range zf.Records { if r.Name == "web.home.example.com." && r.TTL != 30 { t.Errorf("web TTL = %d, want its own 30", r.TTL) } } if _, err := ParseZoneFile("home.example.com", "@@@ ("); err == nil { t.Error("garbage parsed") } } func sortCopy(in []Record) []Record { out := append([]Record(nil), in...) sortRecords(out) return out } func mustSOA(t *testing.T, line string) *dns.SOA { t.Helper() rr, err := dns.NewRR(line) if err != nil { t.Fatal(err) } soa, ok := rr.(*dns.SOA) if !ok { t.Fatalf("%q is not an SOA", line) } return soa }