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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:17:17 +02:00
1 parent 2f25463347
commit 467733a7f9
21 files changed
+505 -2312

No files matched your search

-704
View File
@@ -1,704 +0,0 @@
package localzone
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/miekg/dns"
)
// loadManaged compiles a managed directory the way the app does, so a test
// asserts on what the query path would actually serve rather than on the
// writer's own idea of it.
func loadManaged(t *testing.T, dir string) *Store {
t.Helper()
store, err := Load([]string{dir}, Options{})
if err != nil {
t.Fatalf("load managed dir: %v", err)
}
return store
}
func mustAdd(t *testing.T, m *Managed, origin string, rec Record, def ZoneDefaults) uint32 {
t.Helper()
serial, err := m.Add(origin, rec, def)
if err != nil {
t.Fatalf("add %s %s: %v", rec.Name, rec.Type, err)
}
return serial
}
func zoneDef() ZoneDefaults {
return ZoneDefaults{NS: []string{"ns1.example.com."}}
}
func TestManagedAddWritesAParsableZoneFile(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
first := mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
raw, err := os.ReadFile(filepath.Join(dir, "example.com.zone"))
if err != nil {
t.Fatalf("read zone file: %v", err)
}
text := string(raw)
for _, want := range []string{
"$ORIGIN example.com.",
"$TTL 5m",
"IN SOA",
"IN NS",
"IN A",
"10.0.0.1",
} {
if !strings.Contains(text, want) {
t.Errorf("zone file is missing %q:\n%s", want, text)
}
}
// The second write bumps the serial: a zone file that does not move its
// serial tells nobody the data changed.
second := mustAdd(t, m, "example.com", Record{Name: "db.example.com", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{})
if second != first+1 {
t.Errorf("serial after second write = %d, want %d", second, first+1)
}
store := loadManaged(t, dir)
if got := store.Zones(); len(got) != 1 || got[0] != "example.com." {
t.Fatalf("zones = %v, want [example.com.]", got)
}
res := store.Lookup(dns.Question{Name: "www.example.com.", Qtype: dns.TypeA})
if !res.Found || len(res.Records) != 1 {
t.Fatalf("lookup www = %+v, want one A record", res)
}
if a, ok := res.Records[0].(*dns.A); !ok || a.A.String() != "10.0.0.1" {
t.Errorf("www A = %v, want 10.0.0.1", res.Records[0])
}
// A record written without a TTL inherits the file's $TTL, not the
// library default of 3600.
if ttl := res.Records[0].Header().Ttl; ttl != 60 {
t.Errorf("www TTL = %d, want 60", ttl)
}
}
func TestManagedSnapshotMarksTheConsoleAsTheSource(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if zone == nil {
t.Fatal("snapshot is nil")
}
if !zone.Managed {
t.Error("zone is not marked managed, so the console would refuse to edit it")
}
if len(zone.Records) != 2 || zone.RecordCount != 2 {
t.Fatalf("records = %d, want 2", zone.RecordCount)
}
if zone.Serial == 0 {
t.Error("snapshot has no SOA serial")
}
for _, rec := range zone.Records {
if !rec.Managed {
t.Errorf("record %s %s is not marked managed", rec.Name, rec.Type)
}
if !strings.HasPrefix(rec.Source, dir) {
t.Errorf("record %s %s source = %q, want a path under %s", rec.Name, rec.Type, rec.Source, dir)
}
if rec.Delegation {
t.Errorf("the apex NS set is reported as a delegation: %+v", rec)
}
}
if len(zone.Delegations) != 0 {
t.Errorf("delegations = %+v, want none", zone.Delegations)
}
}
// An operator's own master file is not the console's to rewrite, so its
// records have to be visible with Managed false.
func TestManagedSnapshotOfAnOperatorFileIsNotManaged(t *testing.T) {
dir := t.TempDir()
managed := NewManaged(dir)
mustAdd(t, managed, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
op := filepath.Join(t.TempDir(), "example.com")
if err := os.WriteFile(op, []byte("$ORIGIN example.com.\n$TTL 300\n@ 300 IN SOA ns1 hostmaster 1 3600 600 604800 300\n@ 300 IN NS ns1\nhost 300 IN A 192.0.2.7\n"), 0o644); err != nil {
t.Fatal(err)
}
store, err := Load([]string{dir, op}, Options{})
if err != nil {
t.Fatalf("load: %v", err)
}
zone := store.Snapshot("example.com.", dir)
if zone.Managed {
t.Error("a zone with an operator-managed record is reported as wholly managed")
}
var found bool
for _, rec := range zone.Records {
if rec.Name == "host.example.com." {
found = true
if rec.Managed {
t.Error("the operator's record is marked managed")
}
if rec.Value != "192.0.2.7" {
t.Errorf("operator record value = %q", rec.Value)
}
}
}
if !found {
t.Errorf("the operator's record is missing from the snapshot: %+v", zone.Records)
}
}
func TestManagedDelegationIsACutBelowTheApex(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatalf("set delegation: %v", err)
}
store := loadManaged(t, dir)
zone := store.Snapshot("example.com.", dir)
if len(zone.Delegations) != 1 {
t.Fatalf("delegations = %+v, want one", zone.Delegations)
}
cut := zone.Delegations[0]
if cut.Name != "sub.example.com." {
t.Errorf("delegated name = %q", cut.Name)
}
if strings.Join(cut.Targets, ",") != "ns1.sub.example.com.,ns2.sub.example.com." {
t.Errorf("delegation targets = %v", cut.Targets)
}
if !cut.Managed {
t.Error("delegation is not marked managed")
}
// A cut means the parent answers nothing below it, and the store is what
// decides that.
res := store.Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA})
if !res.Delegated {
t.Errorf("lookup below the cut = %+v, want Delegated", res)
}
// The NS RRset at the cut itself stays answerable, or a resolver can
// never learn the delegation (RFC 1034 §4.3.3).
res = store.Lookup(dns.Question{Name: "sub.example.com.", Qtype: dns.TypeNS})
if !res.Found || len(res.Records) != 2 {
t.Errorf("NS at the cut = %+v, want two records", res)
}
// Above the cut the parent still answers for itself.
if res := store.Lookup(dns.Question{Name: "example.com.", Qtype: dns.TypeNS}); !res.Found {
t.Error("apex NS lookup stopped working after a delegation was added")
}
}
func TestManagedDelegationRejectsTheApexAndEmptyTargets(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "example.com", []string{"ns1.example.com."}, 300, ZoneDefaults{}); err == nil {
t.Error("delegating the apex was accepted")
}
if _, err := m.SetDelegation("example.com", "sub.example.com", nil, 300, ZoneDefaults{}); err == nil {
t.Error("a delegation with no targets was accepted")
}
if _, err := m.SetDelegation("example.com", "elsewhere.test", []string{"ns1.test."}, 300, ZoneDefaults{}); err == nil {
t.Error("a delegation outside the zone was accepted")
}
}
func TestManagedRemoveDelegationDropsTheWholeCut(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
if _, removed, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil || !removed {
t.Fatalf("remove delegation: removed=%v err=%v", removed, err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if len(zone.Delegations) != 0 {
t.Errorf("delegations = %+v, want none left", zone.Delegations)
}
// The parent answers for the name again once the cut is gone.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); res.Delegated {
t.Error("the name is still delegated after the cut was removed")
}
// A second removal is a stale console, not a server error.
if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err == nil {
t.Error("removing a delegation twice was accepted")
}
}
func TestManagedPutReplacesTheRRset(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
for _, target := range []string{"10 mx1.example.com.", "20 mx2.example.com."} {
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: target}, ZoneDefaults{})
}
if _, err := m.Put("example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: "10 mx1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("put: %v", err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var mx int
for _, rec := range zone.Records {
if rec.Type == "MX" {
mx++
}
}
if mx != 1 {
t.Errorf("MX records = %d, want the whole RRset replaced by one", mx)
}
}
func TestManagedDeleteIsScopedToTheRRsetOrTheRecord(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"one"`}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"two"`}, ZoneDefaults{})
if _, removed, err := m.Delete("example.com", "example.com", "TXT", `"two"`); err != nil || !removed {
t.Fatalf("delete one TXT: removed=%v err=%v", removed, err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var txt int
for _, rec := range zone.Records {
if rec.Type == "TXT" {
txt++
}
}
if txt != 1 {
t.Errorf("TXT records = %d, want the one that was not deleted", txt)
}
if _, _, err := m.Delete("example.com", "example.com", "TXT", ""); err != nil {
t.Fatalf("delete the RRset: %v", err)
}
if _, removed, err := m.Delete("example.com", "example.com", "TXT", ""); err == nil || removed {
t.Error("deleting an absent record succeeded")
}
}
func TestManagedRefusesWhatItCannotRoundTrip(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
seed := Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}
mustAdd(t, m, "example.com", seed, ZoneDefaults{})
cases := []struct {
what string
rec Record
}{
{"out of zone", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.1"}},
{"unknown type", Record{Name: "example.com", Type: "BOGUS", Value: "x"}},
{"SOA", Record{Name: "example.com", Type: "SOA", Value: "a. b. 1 2 3 4 5"}},
{"empty value", Record{Name: "example.com", Type: "A", Value: ""}},
{"malformed rdata", Record{Name: "example.com", Type: "A", Value: "not-an-address"}},
{"empty name", Record{Name: "", Type: "A", Value: "10.0.0.1"}},
}
for _, tc := range cases {
if _, err := m.Add("example.com", tc.rec, ZoneDefaults{}); err == nil {
t.Errorf("%s was accepted", tc.what)
}
}
// A duplicate is refused rather than written twice, and neither attempt
// touched the file.
if _, err := m.Add("example.com", seed, ZoneDefaults{}); err == nil {
t.Error("a duplicate record was accepted")
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if len(zone.Records) != 1 {
t.Errorf("records = %d, want only the seed record", len(zone.Records))
}
}
func TestManagedRefusesABadZoneApex(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
for _, origin := range []string{"", ".", "example", "example..com."} {
if _, err := m.Add(origin, Record{Name: "example.com.", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil {
t.Errorf("origin %q was accepted", origin)
}
}
// The one shape that is a zone: an apex with at least two labels.
if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{NS: []string{"ns1.example.com."}}); err != nil {
t.Fatalf("valid origin refused: %v", err)
}
}
// A zone with no SOA cannot say NXDOMAIN, and one with no apex NS has no
// server to name, so the writer refuses to create either.
func TestManagedRefusesAZoneWithNoServerList(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
_, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{})
if err == nil {
t.Fatal("a brand new zone with no NS record was created")
}
if !strings.Contains(err.Error(), "NS") {
t.Errorf("error = %v, want it to say what is missing", err)
}
if _, statErr := os.Stat(filepath.Join(dir, "example.com.zone")); statErr == nil {
t.Error("a refused write left a zone file behind")
}
}
func TestManagedWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
// A wildcard owner and a relative RDATA name: both are the kind of thing
// an operator types and a writer has to preserve.
mustAdd(t, m, "example.com", Record{Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
byKey := map[string]Record{}
for _, rec := range zone.Records {
byKey[rec.Key()] = rec
}
wild, ok := byKey["*.example.com.|A"]
if !ok {
t.Fatalf("the wildcard record was lost: %+v", zone.Records)
}
if !wild.Wildcard {
t.Error("the wildcard record is not flagged as one")
}
if wild.Value != "10.0.0.9" {
t.Errorf("wildcard value = %q", wild.Value)
}
cname, ok := byKey["alias.example.com.|CNAME"]
if !ok {
t.Fatalf("the CNAME was lost: %+v", zone.Records)
}
if cname.Value != "target.example.com." {
t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value)
}
// And the store still answers the wildcard, which is the point of it.
res := loadManaged(t, dir).Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA})
if !res.Found || len(res.Records) != 1 {
t.Errorf("wildcard lookup = %+v, want an answer", res)
}
}
func TestManagedSeedsFromAnExistingZone(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
// The apex and SOA come from a zone that already exists elsewhere, so
// the console can add a record to it without the caller restating them.
_, err := m.Add("example.com", Record{Name: "new.example.com", Type: "A", Value: "10.0.0.5"}, ZoneDefaults{
TTL: 120,
Soa: "ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300",
NS: []string{"ns1.example.com.", "ns2.example.com."},
})
if err != nil {
t.Fatalf("add with seeded zone: %v", err)
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if zone.Serial != 2024010102 {
t.Errorf("serial = %d, want the seeded SOA's serial bumped once by the write", zone.Serial)
}
if zone.DefaultTTL != 120 {
t.Errorf("default TTL = %d, want the seeded 120", zone.DefaultTTL)
}
var ns int
for _, rec := range zone.Records {
if rec.Name == "example.com." && rec.Type == "NS" {
ns++
}
}
if ns != 2 {
t.Errorf("apex NS records = %d, want the two seeded", ns)
}
}
// Every type the API accepts has to survive being written and read back. A
// type in the whitelist that the master-file assembler does not recognise
// would render a line no parse accepts, and the writer's round-trip check
// would refuse the write for every record of that type.
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",
// NAPTR's flags, service and regexp are all character-strings in
// miekg's presentation format, so all three are quoted; a bare
// regexp (as in RFC 3403 §4.1) does not parse.
"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"`,
// miekg presents URI's target as a character-string, so it has to be
// quoted; the samples above pin that.
"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 writer is untested for it", rrtype)
continue
}
dir := t.TempDir()
m := NewManaged(dir)
if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("%s: seed zone: %v", rrtype, err)
}
if _, err := m.Add("example.com", Record{Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample}, ZoneDefaults{}); err != nil {
t.Errorf("%s: add: %v", rrtype, err)
continue
}
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
var found *Record
for i := range zone.Records {
if zone.Records[i].Name == "rec.example.com." && zone.Records[i].Type == rrtype {
found = &zone.Records[i]
}
}
if found == nil {
t.Errorf("%s: the record did not survive the file: %+v", rrtype, zone.Records)
continue
}
// The canonical value is what the API echoed, so the console shows
// the same text the file holds.
if found.TTL != 300 {
t.Errorf("%s: TTL = %d, want 300", rrtype, found.TTL)
}
}
}
func TestManagedWritableAndPath(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "zones")
m := NewManaged(dir)
if !m.Writable() {
t.Error("a directory under a writable parent is not writable")
}
// Asking must not touch the disk: the console calls this on every read.
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("checking writability created the directory (stat err = %v)", err)
}
if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil {
t.Fatalf("add: %v", err)
}
if !m.Writable() {
t.Error("a directory the process just wrote into is not writable")
}
if got := m.Path("example.com."); got != filepath.Join(dir, "example.com.zone") {
t.Errorf("Path = %q", got)
}
if got := NewManaged("").Writable(); got {
t.Error("an unconfigured managed directory is writable")
}
// The probe must not leave files behind.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "example.com.zone" {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("managed directory holds %v, want only the zone file", names)
}
}
// The console's form asks for a short name, because a zone is already chosen.
// A name without a trailing dot therefore means "in this zone", the same way
// it does in a zone file.
func TestManagedQualifiesARelativeOwnerName(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{})
mustAdd(t, m, "example.com", Record{Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
got := map[string]string{}
for _, rec := range zone.Records {
got[rec.Name] = rec.Value
}
if got["www.example.com."] != "10.0.0.1" {
t.Errorf("relative owner became %v, want www.example.com.", got)
}
if got["*.cdn.example.com."] != "10.0.0.2" {
t.Errorf("relative wildcard owner became %v, want *.cdn.example.com.", got)
}
// And the store serves them under the qualified names.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found {
t.Error("the wildcard does not answer under its qualified name")
}
// Deleting by the same short name finds the same record.
if _, removed, err := m.Delete("example.com", "www", "A", ""); err != nil || !removed {
t.Errorf("delete by relative name: removed=%v err=%v", removed, err)
}
// An absolute name outside the zone is still refused.
if _, err := m.Add("example.com", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}, ZoneDefaults{}); 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, and the console is what changes that set.
func TestManagedSOAPrimaryFollowsTheApexNSSet(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
zone := loadManaged(t, dir).Snapshot("example.com.", dir)
if !strings.HasPrefix(zone.Soa, "ns1.example.com. ") {
t.Fatalf("soa = %q, want ns1.example.com. as the primary", zone.Soa)
}
if _, err := m.Put("example.com", Record{Name: "example.com", Type: "NS", TTL: 300, Value: "ns2.example.com."}, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
zone = loadManaged(t, dir).Snapshot("example.com.", dir)
if !strings.HasPrefix(zone.Soa, "ns2.example.com. ") {
t.Errorf("soa = %q after replacing the apex NS set, want ns2.example.com.", zone.Soa)
}
}
// A zone file that is not a zone file must be reported, not silently
// rewritten.
func TestManagedRefusesAFileWhoseOriginMoved(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if err := os.Rename(m.Path("example.com."), m.Path("other.com.")); err != nil {
t.Fatal(err)
}
if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil {
t.Error("a file holding another zone was overwritten")
}
}
// A cut means the names under it belong to another server, so a record there
// is dead data: the store answers them as delegated without ever looking at
// what the zone file says.
func TestManagedRefusesDataBelowADelegation(t *testing.T) {
dir := t.TempDir()
m := NewManaged(dir)
mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{})
if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com."}, 300, ZoneDefaults{}); err != nil {
t.Fatal(err)
}
below := Record{Name: "host.sub.example.com", Type: "A", TTL: 60, Value: "10.0.0.3"}
if _, err := m.Add("example.com", below, ZoneDefaults{}); err == nil {
t.Error("a record below a delegation was accepted")
}
for _, rec := range loadManaged(t, dir).Snapshot("example.com.", dir).Records {
if rec.Name == below.Name {
t.Errorf("the refused record was written anyway: %+v", rec)
}
}
// The delegation itself is still there and still a cut.
if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); !res.Delegated {
t.Error("the cut stopped being a cut")
}
// And the rule follows the cut, not the record: removing the delegation
// makes the same record legal again.
if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil {
t.Fatal(err)
}
if _, err := m.Add("example.com", below, ZoneDefaults{}); err != nil {
t.Errorf("a record under a name that is no longer delegated was refused: %v", err)
}
}
func TestZoneFileName(t *testing.T) {
for origin, want := range map[string]string{
"example.com.": "example.com.zone",
"EXAMPLE.com.": "example.com.zone",
"example.com": "example.com.zone",
"a.b.example.": "a.b.example.zone",
"example.com...": "example.com...zone",
} {
if got := zoneFileName(origin); got != want {
t.Errorf("zoneFileName(%q) = %q, want %q", origin, got, want)
}
}
}
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")
}
// A name that is only a wildcard is not a zone the store can hold.
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, with the owner relative to the origin: that is the
// form this project's master-file parser accepts, so what the writer
// emits is what the loader can read. A bare owner line would parse 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)
}
}
if !strings.Contains(text, "1m IN A") || !strings.Contains(text, "10.0.0.1") {
t.Errorf("the A record is missing:\n%s", text)
}
// And it is a zone file: it parses, and it parses to the same records.
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 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
}
+1 -1
View File
@@ -53,7 +53,7 @@ func RenderZone(origin string, def ZoneDefaults, recs []Record, serial uint32) (
if err := sameRecords(check.recs, f.recs); err != nil {
return "", fmt.Errorf("the zone file for %s did not render correctly: %w", origin, err)
}
return strings.Replace(text, FileHeader, ZoneSourceHeader, 1), nil
return text, nil
}
// ZoneSourceHeader tops a rendered zone that an external object owns.
@@ -3,8 +3,6 @@ package localzone
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
@@ -16,22 +14,11 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// DefaultZoneTTL is the $TTL a console-written zone file gets when the request
// does not name one. It is also the SOA minimum, so a record written without a
// DefaultZoneTTL is the $TTL a rendered zone gets when its spec does not name
// one. It is also the SOA minimum, so a record written without a
// TTL of its own inherits it.
const DefaultZoneTTL uint32 = 300
// FileHeader tops every console-written zone file. The file is rewritten
// whole on each edit, so hand-written commentary below it does not survive;
// operator-owned data belongs in blocklist.local_zones, which the API never
// touches.
const FileHeader = `; fancy-dns console-managed zone data.
;
; Rewritten by the admin API on every change: edits made here by hand are lost
; the next time a record in this zone is added, changed or deleted. Keep
; hand-maintained data in blocklist.local_zones instead.
`
// editableTypes are the RR types the API accepts. It is a subset of what the
// master-file assembler recognises, and of the types a zone browser can put
// in a form: SOA is zone metadata the writer owns, and AXFR/IXFR/ANY are not
@@ -68,124 +55,6 @@ func isEditableType(rrtype string) bool {
return false
}
// Managed is the console-managed zone directory: the only zone data the admin
// API is allowed to rewrite. It is one file per apex, named after it, holding
// the SOA, the apex NS set and whatever records the console has written. A
// zone whose bulk lives in an operator's master file can still gain a managed
// file, because Load merges every file it is given under one apex.
type Managed struct {
dir string
}
// NewManaged wraps a directory. The directory is created on the first write,
// never here: a read of a zone directory that does not exist is a legitimate
// state, not an error.
func NewManaged(dir string) *Managed { return &Managed{dir: dir} }
// Dir is the managed directory.
func (m *Managed) Dir() string {
if m == nil {
return ""
}
return m.dir
}
// Path is the file holding one apex's managed data.
func (m *Managed) Path(origin string) string {
if m == nil || m.dir == "" {
return ""
}
return filepath.Join(m.dir, zoneFileName(origin))
}
// ErrNotManaged is why a write did not happen because the console is not
// allowed to write zone data: no managed directory is configured, or the one
// that is cannot be written to. It is not "not found", so the API answers a
// delete with 400 for it and 404 for a record that simply is not there.
var ErrNotManaged = errors.New("local zone writes are disabled")
// Check reports whether a write can proceed, naming which of the two reasons
// stopped it.
func (m *Managed) Check() error {
if m == nil || m.dir == "" {
return fmt.Errorf("%w: set blocklist.managed_zones to let the console write zone data", ErrNotManaged)
}
if !m.Writable() {
return fmt.Errorf("%w: the managed zone directory %s is not writable", ErrNotManaged, m.dir)
}
return nil
}
// Remove deletes a zone's managed file, which is how a zone the console
// created stops being authoritative. It refuses a zone that also exists in an
// operator's master file: deleting that file would not remove the zone, and
// reporting success would be a lie.
func (m *Managed) Remove(origin string, wholeZoneIsManaged bool) (bool, error) {
canonical, err := NormalizeOrigin(origin)
if err != nil {
return false, err
}
if err := m.Check(); err != nil {
return false, err
}
if !wholeZoneIsManaged {
return false, fmt.Errorf("%s also has records outside %s; remove those first", canonical, m.dir)
}
path := m.Path(canonical)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// Exists reports whether the managed directory is there. It is not an error
// when it is not: the directory holds no zones until the console writes one.
func (m *Managed) Exists() bool {
if m == nil || m.dir == "" {
return false
}
info, err := os.Stat(m.dir)
return err == nil && info.IsDir()
}
// Writable reports whether a write can succeed: a directory that is (or whose
// nearest existing parent is) one the process may write to. It creates
// nothing, because the API calls it on every read to tell the console whether
// its edit controls are live.
func (m *Managed) Writable() bool {
if m == nil || m.dir == "" {
return false
}
dir := m.dir
for {
info, err := os.Stat(dir)
if err == nil {
if !info.IsDir() {
return false
}
probe, err := os.CreateTemp(dir, ".fancy-dns-probe")
if err != nil {
return false
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return true
}
if !os.IsNotExist(err) {
return false
}
parent := filepath.Dir(dir)
if parent == dir {
return false
}
dir = parent
}
}
// ZoneDefaults seeds a managed file for an apex that has none yet: the SOA and
// apex NS set of a zone that already exists elsewhere, or the NS targets the
// request itself supplies.
@@ -199,172 +68,6 @@ type ZoneDefaults struct {
NS []string
}
// Add writes one record into a zone, creating the zone's managed file when it
// has none. The record is canonicalised first, so what the API echoes back is
// what a zone-file parse will produce.
func (m *Managed) Add(origin string, rec Record, def ZoneDefaults) (uint32, error) {
var serial uint32
err := m.mutate(origin, def, func(f *fileState) error {
rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value)
if err != nil {
return err
}
for _, have := range f.recs {
if have.Key() == rec.Key() && have.Value == rec.Value {
return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, origin)
}
}
f.recs = append(f.recs, rec)
return nil
}, &serial)
return serial, err
}
// Put replaces the whole RRset of one name and type. This is the console's
// edit: a name and type hold one logical record (an A, a set of MXs, a TXT
// RRset), and replacing it wholesale is what an operator means by "change
// this row".
func (m *Managed) Put(origin string, rec Record, def ZoneDefaults) (uint32, error) {
var serial uint32
err := m.mutate(origin, def, func(f *fileState) error {
rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value)
if err != nil {
return err
}
kept := f.recs[:0]
for _, have := range f.recs {
if have.Key() == rec.Key() {
continue
}
kept = append(kept, have)
}
f.recs = append(kept, rec)
return nil
}, &serial)
return serial, err
}
// Delete removes one record, or the whole RRset when value is empty. It
// reports whether anything was removed: "delete" on a name that is not there
// is a stale console, not a server error.
func (m *Managed) Delete(origin, name, rrtype, value string) (uint32, bool, error) {
var serial uint32
var removed bool
err := m.mutate(origin, ZoneDefaults{}, func(f *fileState) error {
owner, err := ownerInZone(f.origin, name)
if err != nil {
return err
}
up := strings.ToUpper(strings.TrimSpace(rrtype))
if !isEditableType(up) {
return fmt.Errorf("type %s cannot be written through the API", up)
}
// An empty value means "the whole RRset", so there is nothing to
// parse.
want := ""
if value != "" {
rec, err := newRecord(f.origin, owner, up, f.ttl, value)
if err != nil {
return err
}
want = rec.Value
}
key := owner + "|" + up
kept := f.recs[:0]
for _, have := range f.recs {
hit := have.Key() == key && (want == "" || have.Value == want)
if hit {
removed = true
continue
}
kept = append(kept, have)
}
f.recs = append([]Record(nil), kept...)
if !removed {
return fmt.Errorf("no managed record matches %s %s in %s", name, up, f.origin)
}
return nil
}, &serial)
if err != nil {
return 0, false, err
}
return serial, removed, nil
}
// SetDelegation hands a name below the apex, and everything under it, to other
// servers: it writes the NS RRset of that name.
func (m *Managed) SetDelegation(origin, name string, targets []string, ttl uint32, def ZoneDefaults) (uint32, error) {
if len(targets) == 0 {
return 0, errors.New("a delegation needs at least one NS target")
}
cut, err := NormalizeName(name)
if err != nil {
return 0, fmt.Errorf("delegated name: %w", err)
}
if cut == strings.ToLower(dns.Fqdn(origin)) {
return 0, errors.New("a delegation must be below the zone apex; the apex NS set is the zone's own server list")
}
if def.TTL == 0 {
def.TTL = ttl
}
var serial uint32
err = m.mutate(origin, def, func(f *fileState) error {
for _, target := range targets {
t, err := newRecord(f.origin, cut, "NS", f.ttl, target)
if err != nil {
return fmt.Errorf("delegation target %q: %w", target, err)
}
// Replace one target at a time, so re-submitting the form does
// not accumulate duplicates and a second target does not drop
// the first.
kept := make([]Record, 0, len(f.recs)+1)
for _, have := range f.recs {
if have.Key() == t.Key() && have.Value == t.Value {
continue
}
kept = append(kept, have)
}
f.recs = append(kept, t)
}
return nil
}, &serial)
return serial, err
}
// RemoveDelegation deletes the NS RRset of a name, which is the whole cut.
func (m *Managed) RemoveDelegation(origin, name string) (uint32, bool, error) {
return m.Delete(origin, name, "NS", "")
}
// mutate is the whole write path: read, change, bump the serial, render, prove
// the rendering parses back to the same records, then write atomically.
func (m *Managed) mutate(origin string, def ZoneDefaults, fn func(*fileState) error, serial *uint32) error {
if err := m.Check(); err != nil {
return err
}
canonical, err := NormalizeOrigin(origin)
if err != nil {
return err
}
f, err := m.load(canonical)
if err != nil {
return err
}
if f == nil {
f, err = newFileState(canonical, def)
if err != nil {
return err
}
}
if err := fn(f); err != nil {
return err
}
if err := f.finish(); err != nil {
return err
}
return m.save(f, serial)
}
// fileState is one managed file's content in presentation form.
type fileState struct {
origin string
@@ -472,7 +175,7 @@ func (f *fileState) checkCuts() error {
// indented under it.
func (f *fileState) render() string {
var sb strings.Builder
sb.WriteString(FileHeader)
sb.WriteString(ZoneSourceHeader)
fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin)
fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl))
soa := f.soa
@@ -509,86 +212,6 @@ func sortedNames(byName map[string][]Record) []string {
return out
}
// save writes the file atomically, and only after proving that what it
// rendered is what a parse of that text produces. A writer bug would otherwise
// show up as a zone that silently stopped answering.
func (m *Managed) save(f *fileState, serial *uint32) error {
text := f.render()
check, err := parseFileState(f.origin, text)
if err != nil {
return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err)
}
if err := sameRecords(check.recs, f.recs); err != nil {
return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err)
}
if err := os.MkdirAll(m.dir, 0o755); err != nil {
return fmt.Errorf("managed zone directory %s: %w", m.dir, err)
}
path := m.Path(f.origin)
tmp, err := os.CreateTemp(m.dir, ".fancy-dns-zone-*")
if err != nil {
return fmt.Errorf("managed zone directory %s: %w", m.dir, err)
}
name := tmp.Name()
defer func() {
// A rename that never happened must not leave a stray temp file.
_ = os.Remove(name)
}()
if _, err := tmp.WriteString(text); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(name, 0o644); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
if serial != nil && f.soa != nil {
*serial = f.soa.Serial
}
return nil
}
// load reads one apex's managed file. A missing file is (nil, nil): the zone
// has no console-managed data yet, which is the normal state.
func (m *Managed) load(origin string) (*fileState, error) {
path := m.Path(origin)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
z, err := zonefile.Parse(f, zonefile.Options{Origin: origin, File: path, BaseDir: m.dir})
if err != nil {
return nil, err
}
if strings.ToLower(z.Origin) != origin {
return nil, fmt.Errorf("%s holds zone %s, not %s", path, z.Origin, origin)
}
return fileStateFromZone(z, origin), nil
}
// ManagedRecords reads the records the console owns for one apex, which is the
// subset the API may rewrite.
func (m *Managed) ManagedRecords(origin string) ([]Record, error) {
f, err := m.load(origin)
if err != nil || f == nil {
return nil, err
}
return append([]Record(nil), f.recs...), nil
}
// fileStateFromZone converts a parsed master file into presentation form.
func fileStateFromZone(z *zonefile.Zone, origin string) *fileState {
ttl := z.DefaultTTL
@@ -786,11 +409,6 @@ func relativeOwner(name, origin string) string {
return name
}
// zoneFileName maps an apex to its managed file.
func zoneFileName(origin string) string {
return strings.TrimSuffix(strings.ToLower(origin), ".") + ".zone"
}
// synthSOA builds a zone's start of authority.
func synthSOA(origin, primary string, serial, ttl uint32) (*dns.SOA, error) {
if primary == "" {
+267
View File
@@ -0,0 +1,267 @@
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
}