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.
705 lines
27 KiB
Go
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
|
|
}
|