Files
fancy-dns/internal/localzone/managed_test.go
T
developer 504512f247 Add a Zones console: create and manage local zones, records and delegations
The console could not see local zones at all. `blocklist.local_zones` compiled
a store that nothing read back: there was no API for a zone, its records, its
delegations or the file a record came from, and no way to change one without
editing YAML and reloading. The Domains page is the blocklist's inline rules,
not local data, so nothing in the console covered the half of the server that
answers authoritatively from disk. There is now a Zones page and the endpoints
behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`,
`POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`.

**The console owns one directory and nothing else.** `blocklist.managed_zones`
holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the
records added there, and it is compiled by the same `localzone.Load` as the
operator's files — so a record added from the console is served on the next
query, with no reload and no restart. Files in `blocklist.local_zones` are
listed and shown read-only and are never rewritten; the key defaults to empty,
so a deployment that never sets it gets a zone browser and no zone writes.
Which file a record came from is now part of the store (`zone.files`, keyed by
the index the store already uses), so the snapshot can say "console" or "your
file" per record; the query path does not read it.

**The writer proves its own output.** A record is validated by the project's own
master-file parser, so `www` means `www.<zone>` and a bare CNAME target is
completed exactly as it would be in a zone file; the file is rendered, and the
rendering is re-parsed and compared with the records it meant to write before
the file is renamed into place. A writer bug that dropped a record would
otherwise present as a zone that silently stopped answering. A zone that cannot
answer is refused rather than created: no SOA means no NXDOMAIN or NODATA
authority, and no apex NS means nothing is delegated and no server is named. The
SOA's primary follows the apex NS set — a zone whose SOA names a server outside
its own NS set hands out a referral no resolver can follow — and the serial
moves on every write.

**A delegation takes the names under it, so a record below a cut is refused.**
The store answers those names as delegated without reading the zone file, which
would make the record data that looks alive and never answers. `DELETE
/api/zones?zone=` exists for the same reason from the other side: a zone the
console created is a real zone, so it says NXDOMAIN for what it does not hold,
and deleting a record is not what gives a name back to upstream. It is
refused for a zone that also has records outside the managed directory, because
removing that file would remove none of the zone.

**Two bugs the work turned up.** `engine.applyConfig` published local-record
metrics only when `blocklist.local_zones` was non-empty, so a zone the console
created was served but never counted; the gate is now the store's own record
count. And the record-value renderer fell back to `RR.String()` for every type
it did not know, which is the whole line rather than the RDATA — it wrote a
name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would
have fed that back as a record value on the next edit.

Every write recompiles the store and flushes the cache. Which names changed is
not knowable from the API, and a cached upstream answer for a name the zone now
answers is simply wrong, so the flush is blunt on purpose.

The wire shapes are type aliases in `internal/api` rather than copies, so there
is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every
level with a record and a delegation actually present — an empty list only
proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only
default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added
through the API is answered authoritatively on the next query, a cut stops the
parent answering below it, and deleting the zone hands the name back.

Verified against a running instance built from this tree: records and
wildcards answer with `aa`, relative names are qualified, a record below a cut
is refused with a message that says why, and the file on disk is a zone file
an operator can read. The console page is typechecked and built; the browser was
not available in this round, so it has not been looked at.
2026-09-28 07:45:16 +00:00

705 lines
27 KiB
Go

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
}