Files
matsandClaude Sonnet 5.5 467733a7f9 Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace
Zones, address redirects, inline rules, lists and groups are cluster objects now
(see the previous commits and deploy/MIGRATION.md), so the mechanisms that held
them are gone:

* internal/kubezones and the resolver's service-account access to a ConfigMap,
  the address-overrides read/write path and its poller, the zone-file poller and
  the Local zones editor with its /api/local-zones routes.
* localzone.Managed, the directory of console-written master files, and the
  blocklist.managed_zones key. The resolver now writes no zone data at all; its
  zone hooks only list. The shared half of that file (the record validator, the
  renderer and the round-trip check) stays as zonestate.go, because DNSZone
  rendering is built on it. Its tests are ported to RenderZone, which keeps the
  coverage of every editable record type, wildcards, relative owners, the
  SOA/NS rule and the delegation rule.
* Tests of the removed write paths: the contract test now builds its zone from a
  DNSZone, and a new test pins that every zone write route on the resolver is
  refused and changes nothing.

Standalone installs keep editing master files on disk and SIGHUP, and the
YAML-backed domain, client, upstream and settings editors, which have not moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:17:17 +02:00

268 lines
9.8 KiB
Go

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
}