diff --git a/AGENTS.md b/AGENTS.md index faf0c6a..9a65875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ Cache is a single shard-local lookup; only a miss touches the network. |---|---|---| | Scope | Full production | clustering, metrics, ACLs, query logs, hot reload, Docker, k8s, CI | | Config store | YAML + DNS master files | reviewable/diffable in git | +| Console-written zone data | Its own directory, `blocklist.managed_zones`, one file per apex | the console is a real operator surface, so it needs somewhere to write; a separate directory keeps the operator's master files read-only to it, and an unset key keeps the console read-only by default | | Cluster sync | Valkey (Redis protocol) | simple, mature Go client, easy local dev | | Secure DNS | DoT + DoH, **no DNSSEC validation** | deliberately out of scope; AD/CD/extended-RCODE are passed through only | | Hot cache | Sharded LRU in memory, bbolt for snapshots | no I/O on the hot path; warm restart | @@ -113,6 +114,18 @@ Break these and you break the design, not just the test. - **The Go heap does not account for goroutine stacks, runtime metadata, or the bbolt mmap.** `memory.reserve` exists to cover them; the cache snapshot and query log are mmapped and live outside the heap entirely. +- **The zone writer proves its own output.** Every record is validated by the + project's own master-file parser, 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 lost a record would otherwise show + up as a zone that silently stopped answering. +- **A zone the console creates is a real zone.** It is authoritative, so it + answers NXDOMAIN for the names it does not hold, and `DELETE /api/zones` is + the only thing that hands a name back to upstream. Deleting a record is not, + and the API never pretends it is. +- **A delegation takes the names under it.** The store answers them as + delegated without reading the zone file, so a record at or below a cut is + refused rather than written as data that looks alive and never answers. - **Unknown config keys are silently ignored** (a settled decision, pinned by `TestLoadIgnoresUnknownKeys` in `internal/config`: `yaml` `KnownFields(true)` was considered and rejected as a behaviour change for diff --git a/README.md b/README.md index 57b76a7..56b9981 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ file. | `blocklist.response_ttl` | duration | `60s` | TTL of synthesised answers | | `blocklist.redirect_name` | name | — | required when `default_action: redirect` | | `blocklist.local_zones` | list of globs | empty | RPZ policy zones and/or local authoritative data, read from master files | +| `blocklist.managed_zones` | path | empty | the only directory the admin console may **write** zone data into, one `.zone` per zone; empty keeps the console's Zones page read-only | | `blocklist.block_entries` / `allow_entries` | list of lines | empty | inline rules, compiled as `inline:block` / `inline:allow` and applied before every list | | `blocklist.parallelism` | int | `4` | **unused** — compilation is single-threaded | | `blocklist.lists[].name` | string | — | required, unique | @@ -430,6 +431,14 @@ records mean and where they are matched: one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example` is a policy zone. +**Two zone pages, two ownership models.** **Local zones** edits the RFC 1035 +master files themselves (in Kubernetes, one file per zone in the +`fancy-dns-zones` ConfigMap; standalone, the files named by +`blocklist.local_zones`). **Zones** is the record-level view and the only one +that writes a directory of its own, `blocklist.managed_zones`. They are +separate on purpose: the first edits what you wrote, the second writes what it +owns. Neither rewrites the other's files. + **Edit zones in the console.** Open **Local zones** to create a zone or edit its RFC 1035 master-file records. In Kubernetes, the editor stores each file in the `fancy-dns-zones` ConfigMap; both replicas watch the projected files and reload @@ -438,6 +447,35 @@ saving. The pod's service account can read and patch only this ConfigMap. The ConfigMap's `data` is intentionally left out of the GitOps manifest so console edits persist across reconciliation. For a standalone install, manage zone files on disk and reload the configuration. +**Zones the console writes.** `blocklist.managed_zones` names a directory the +admin console owns: one `.zone` per zone, holding the SOA, the apex NS +set and whatever records were added there. It is a zone source like any other — +`localzone.Load` compiles it alongside `local_zones` — so a record added from +the console is served on the next query, with no reload and no restart. The +master files in `local_zones` are never rewritten: they are listed in the +console's Zones page and shown as read-only. Without `managed_zones` the page +is a browser and nothing else. + +Four rules the writer holds to, all of them because the alternative is a zone +that silently stops answering: + +* A record line is validated by the project's own master-file parser, so an RDATA + name written without a trailing dot means the same thing here as in a zone + file, and the value the API echoes is the value the file holds. +* What the writer renders is re-parsed and compared with what it meant to write + before anything touches the disk. A round-trip that loses a record is a + writer bug, caught there rather than by an operator noticing an empty zone. +* A zone needs an SOA (or it cannot answer NXDOMAIN/NODATA) and an NS record at + the apex (or it delegates nothing and has no server to name), so a brand new + zone is started with one of the two. The SOA's primary follows the apex NS + set, and the serial moves on every write. +* A record at or below a delegation is refused: the store answers those names as + delegated and never looks at the zone file, so it would be data that looks + alive and never answers. + +A record is written by rewriting the whole file, so a file under +`managed_zones` is the console's to own; hand-maintained data belongs in +`local_zones`. Each file says so in its header. ## Blocklists @@ -506,6 +544,13 @@ is on. | GET | `/api/domains/check?name=…` | — | explain how a name would be treated (the fastest "why isn't this blocked" tool) | | POST | `/api/domains` | yes | add an inline rule | | DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule | +| GET | `/api/zones` | — | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (`error`) | +| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) | +| PUT | `/api/zones/records` | yes | replace the whole RRset of one name and type | +| DELETE | `/api/zones/records?zone=&name=&type=&value=` | yes | remove one record, or the RRset when `value` is empty | +| POST | `/api/zones/delegations` | yes | delegate a name to other servers (`{"zone","name","targets":[…],"ttl"}`); the delegation's targets become the apex NS set of a zone that has none | +| DELETE | `/api/zones/delegations?zone=&name=` | yes | remove a cut | +| DELETE | `/api/zones?zone=` | yes | delete a zone the console owns, so its names go back to whatever answered for them before; refused for a zone with records outside the managed directory | | GET | `/api/clients` | — | ACL groups and the default policy | | PUT | `/api/clients` | yes | replace the ACL (`{"groups":[…],"default_policy":…}`); validates, writes the file, swaps, broadcasts | | GET | `/api/upstreams` | — | pool state, health, latency, breaker | diff --git a/STATUS.md b/STATUS.md index 90ddcd8..d305cca 100644 --- a/STATUS.md +++ b/STATUS.md @@ -157,6 +157,56 @@ the fix list below. ## Fixed this round +- **The console could not see local zones at all, so it could not manage + overrides or delegations.** `blocklist.local_zones` compiled a store that + nothing could read back: there was no API for a zone, its records, its + delegations or the file each 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 this half of + what the server does. There is now a **Zones** page and the six endpoints + behind it (`GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, + `POST`/`DELETE /api/zones/delegations`, `DELETE /api/zones?zone=`). + + The write side is the interesting part, because the only place a zone can be + changed without a human editing YAML is a file on disk: + + - **The console owns `blocklist.managed_zones` and nothing else.** One + `.zone` per zone, holding the SOA, the apex NS set and the records + added there. 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. Files in `local_zones` are listed and shown read-only, and the + config key defaults to empty so a deployment that never sets it cannot have + zone data rewritten behind its back. `TestZoneWritesNeedAManagedDirectory` + pins that half. + - **The writer proves its own output.** A record is validated by the + project's own master-file parser (so `www` means `www.` and a bare + CNAME target is completed, the same way it is in a zone file), the file is + rendered, and that rendering is re-parsed and compared with the records it + meant to write *before* the file is renamed into place. A round-trip that + drops a record is a writer bug found there rather than by an operator + noticing a zone that stopped answering. + - **A zone that cannot answer is refused, not created.** No SOA means no + NXDOMAIN or NODATA authority, and no apex NS means nothing to delegate to + and no server to name, so a new zone has to start with one of the two. The + SOA's primary follows the apex NS set, the serial moves on every write, and + a record at or below a delegation is refused — the store answers those names + as delegated and never reads the file, so it would be data that looks alive + and never answers. + - **The engine's gate was the config list, not the store.** + `engine.applyConfig` only published local-record metrics when + `blocklist.local_zones` was non-empty, so a zone the console created was + served but uncounted. The gate is now the store's own record count. + - `Zone`/`ZoneRecord`/`ZoneDelegation` are type *aliases* in `internal/api` + rather than copies: one set of json tags, pinned by + `TestZoneEndpointKeySets` at every level, with the record list filled from a + real write so the assertion is about a record and not about an envelope. + + Verified end to end against a running instance: a record added through the + API answers authoritatively (`aa`) on the next query, a cut stops the parent + answering below it, and deleting the zone hands the name back. The console + page itself was not looked at in a browser in this round — the browser was + unavailable — so it is typechecked and built, not visually verified. + - **A client that connected and said nothing held the whole shutdown open.** The stream accept loop waits for its connections before `Stop` returns, and a connection parked in `ReadMsg` with no deadline in front of it only ends when diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 7f65958..cfaf074 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -113,6 +113,12 @@ blocklist: response_ttl: 60s # RPZ-style policy zones and/or local authoritative data, as master files. local_zones: [] + # The one directory the admin console may WRITE zone data into: one + # .zone per zone, holding the SOA, the apex NS set and whatever the + # console created. Files in local_zones are read-only to the console, and + # are never rewritten by it. Empty keeps the console's Zones page + # read-only, so nothing is written behind your back. + managed_zones: "" # Inline rules. "*.example.com" covers subdomains, "**.example.com" covers # the apex too, a bare name matches only itself. block_entries: [] diff --git a/internal/api/api.go b/internal/api/api.go index 8eef073..46474b3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -75,6 +75,23 @@ type Hooks struct { LocalZones func(ctx context.Context) ([]ZoneFile, error) SaveLocalZone func(ctx context.Context, name, content string) error DeleteLocalZone func(ctx context.Context, name string) error + // ListZones returns the compiled local zones with their records and + // delegations, and whether the API may rewrite them. + ListZones func() (any, error) + // AddZoneRecord adds one record to a local zone and persists it. + AddZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error) + // PutZoneRecord replaces the whole RRset of one name and type. + PutZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error) + // DeleteZoneRecord removes a record, or its whole RRset when value is + // empty, and persists the change. + DeleteZoneRecord func(ctx context.Context, zone, name, rrtype, value string) error + // DeleteZone removes a zone the console owns, so its names go back to + // whatever answered for them before. + DeleteZone func(ctx context.Context, zone string) error + // AddZoneDelegation hands a name below an apex to other servers. + AddZoneDelegation func(ctx context.Context, in ZoneDelegationInput) (any, error) + // DeleteZoneDelegation removes a cut and persists the change. + DeleteZoneDelegation func(ctx context.Context, zone, name string) error // GetACL returns the ACL groups. GetACL func() any // PutACL validates and persists new ACL groups. @@ -272,6 +289,13 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false)) mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true)) mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true)) + mux.Handle("GET "+base+"zones", s.authed(s.handleZones, false)) + mux.Handle("DELETE "+base+"zones", s.authed(s.handleZoneDelete, true)) + mux.Handle("POST "+base+"zones/records", s.authed(s.handleZoneRecordAdd, true)) + mux.Handle("PUT "+base+"zones/records", s.authed(s.handleZoneRecordPut, true)) + mux.Handle("DELETE "+base+"zones/records", s.authed(s.handleZoneRecordDelete, true)) + mux.Handle("POST "+base+"zones/delegations", s.authed(s.handleZoneDelegationAdd, true)) + mux.Handle("DELETE "+base+"zones/delegations", s.authed(s.handleZoneDelegationDelete, true)) mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false)) mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true)) mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false)) diff --git a/internal/api/harness_test.go b/internal/api/harness_test.go index 6399a11..ced2c01 100644 --- a/internal/api/harness_test.go +++ b/internal/api/harness_test.go @@ -210,6 +210,34 @@ func (f *fakeHooks) hooks() Hooks { f.record("DeleteDomain", func() { f.domainDel = append(f.domainDel, domainDelete{name, action, list}) }) return f.err("DeleteDomain") }, + ListZones: func() (any, error) { + f.record("ListZones", func() {}) + return map[string]any{"zones": []any{}}, f.err("ListZones") + }, + AddZoneRecord: func(_ context.Context, in ZoneRecordInput) (any, error) { + f.record("AddZoneRecord", func() {}) + return map[string]any{"ok": true, "serial": 7}, f.err("AddZoneRecord") + }, + PutZoneRecord: func(_ context.Context, in ZoneRecordInput) (any, error) { + f.record("PutZoneRecord", func() {}) + return map[string]any{"ok": true, "serial": 8}, f.err("PutZoneRecord") + }, + DeleteZoneRecord: func(_ context.Context, zone, name, rrtype, value string) error { + f.record("DeleteZoneRecord", func() {}) + return f.err("DeleteZoneRecord") + }, + DeleteZone: func(_ context.Context, zone string) error { + f.record("DeleteZone", func() {}) + return f.err("DeleteZone") + }, + AddZoneDelegation: func(_ context.Context, in ZoneDelegationInput) (any, error) { + f.record("AddZoneDelegation", func() {}) + return map[string]any{"ok": true}, f.err("AddZoneDelegation") + }, + DeleteZoneDelegation: func(_ context.Context, zone, name string) error { + f.record("DeleteZoneDelegation", func() {}) + return f.err("DeleteZoneDelegation") + }, GetACL: func() any { f.record("GetACL", func() {}) return map[string]any{"groups": []any{}} diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 008cff2..4f370ec 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -30,6 +30,13 @@ func TestRouteTableHappyPath(t *testing.T) { {name: "domain check", method: http.MethodGet, path: "/api/domains/check?name=a.example.com", hook: "DomainCheck", want: `"a.example.com"`}, {name: "domain add", method: http.MethodPost, path: "/api/domains", body: `{"name":"a.example.com","action":"block"}`, hook: "AddDomain", want: `"ok":true`}, {name: "domain delete", method: http.MethodDelete, path: "/api/domains/a.example.com", hook: "DeleteDomain", want: `"ok":true`}, + {name: "zones", method: http.MethodGet, path: "/api/zones", hook: "ListZones", want: `"zones"`}, + {name: "zone record add", method: http.MethodPost, path: "/api/zones/records", body: `{"zone":"example.com.","name":"www","type":"A","value":"10.0.0.1"}`, hook: "AddZoneRecord", want: `"ok":true`}, + {name: "zone record put", method: http.MethodPut, path: "/api/zones/records", body: `{"zone":"example.com.","name":"www","type":"A","value":"10.0.0.1"}`, hook: "PutZoneRecord", want: `"ok":true`}, + {name: "zone record delete", method: http.MethodDelete, path: "/api/zones/records?zone=example.com.&name=www.example.com.&type=A", hook: "DeleteZoneRecord", want: `"ok":true`}, + {name: "zone delegation add", method: http.MethodPost, path: "/api/zones/delegations", body: `{"zone":"example.com.","name":"sub","targets":["ns1.sub.example.com."]}`, hook: "AddZoneDelegation", want: `"ok":true`}, + {name: "zone delegation delete", method: http.MethodDelete, path: "/api/zones/delegations?zone=example.com.&name=sub.example.com.", hook: "DeleteZoneDelegation", want: `"ok":true`}, + {name: "zone delete", method: http.MethodDelete, path: "/api/zones?zone=example.com.", hook: "DeleteZone", want: `"ok":true`}, {name: "clients get", method: http.MethodGet, path: "/api/clients", hook: "GetACL", want: `"groups"`}, {name: "clients put", method: http.MethodPut, path: "/api/clients", body: `{"groups":[],"default_policy":"allow"}`, hook: "PutACL", want: `"version":42`}, {name: "upstreams get", method: http.MethodGet, path: "/api/upstreams", hook: "GetUpstream", want: `"policy":"parallel"`}, @@ -107,6 +114,7 @@ func TestReadRoutesDoNotMutate(t *testing.T) { {http.MethodGet, "/api/blocklists"}, {http.MethodGet, "/api/domains"}, {http.MethodGet, "/api/domains/check?name=a.example.com"}, + {http.MethodGet, "/api/zones"}, {http.MethodGet, "/api/clients"}, {http.MethodGet, "/api/upstreams"}, {http.MethodGet, "/api/logs"}, @@ -124,6 +132,8 @@ func TestReadRoutesDoNotMutate(t *testing.T) { mutations := []string{ "RefreshBlocklists", "ToggleBlocklist", "AddDomain", "DeleteDomain", "PutACL", "PutUpstream", "ClearLog", "FlushCache", + "AddZoneRecord", "PutZoneRecord", "DeleteZoneRecord", + "AddZoneDelegation", "DeleteZoneDelegation", "DeleteZone", } for _, m := range mutations { if h.fake.count(m) != 0 { diff --git a/internal/api/zones.go b/internal/api/zones.go new file mode 100644 index 0000000..4ffbd44 --- /dev/null +++ b/internal/api/zones.go @@ -0,0 +1,261 @@ +package api + +import ( + "errors" + "net/http" + "strings" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" +) + +// The local-zone payloads are aliases rather than copies: the record, zone +// and delegation shapes are defined once in internal/localzone, where the +// writer produces them and the store's own snapshot test pins their fields. +// A copy here would be a second set of json tags to keep in step with the +// console, which is exactly the drift the contract tests in internal/app +// exist to catch. +type ( + // ZoneRecord is one authoritative record. + ZoneRecord = localzone.Record + // Zone is one compiled local zone. + Zone = localzone.Zone + // ZoneDelegation is a cut below an apex. + ZoneDelegation = localzone.Delegation +) + +// ZonesResponse is GET /api/zones. +// +// Every key is always present, and Types is the set of record types the write +// endpoints accept, so the console builds its type picker from the server +// instead of keeping its own list. +type ZonesResponse struct { + // Zones are the compiled local zones, sorted by apex. + Zones []localzone.Zone `json:"zones"` + // Files are every master file the zones were compiled from. + Files []string `json:"files"` + // ManagedDir is the directory the API may rewrite, "" when the console + // has no managed zone directory configured (blocklist.managed_zones). + ManagedDir string `json:"managed_dir"` + // Writable is whether a write to ManagedDir would succeed. + Writable bool `json:"writable"` + // Types are the record types the write endpoints accept. + Types []string `json:"types"` + // Records is the total number of records across every zone. + Records int `json:"records"` + // BuiltAt is when the zone store was compiled. + BuiltAt time.Time `json:"built_at"` + // Error is the reason the store is stale, "" when it is current. It is + // here so a broken master file is visible in the console rather than + // only in the log. + Error string `json:"error"` +} + +// ZoneRecordInput adds or replaces one record. A TTL of 0 means the zone's +// default, which is what a record written without a TTL column inherits. +type ZoneRecordInput struct { + Zone string `json:"zone"` + Name string `json:"name"` + Type string `json:"type"` + TTL uint32 `json:"ttl"` + Value string `json:"value"` +} + +// ZoneDelegationInput hands a name below an apex, and everything under it, to +// other servers. +type ZoneDelegationInput struct { + Zone string `json:"zone"` + Name string `json:"name"` + Targets []string `json:"targets"` + TTL uint32 `json:"ttl"` +} + +// ZoneWriteResponse is what a successful record or delegation change answers. +type ZoneWriteResponse struct { + OK bool `json:"ok"` + // Zone is the canonical apex the change landed in. + Zone string `json:"zone"` + // Serial is the zone's SOA serial after the write. It moves on every + // change, so it is the cheapest way for the console to tell that what it + // is looking at has been rewritten. + Serial uint32 `json:"serial"` + // Removed is set on a delete that matched something. + Removed bool `json:"removed"` +} + +// handleZones returns the compiled local zones. +func (s *Server) handleZones(w http.ResponseWriter, _ *http.Request) { + if s.hooks.ListZones == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + v, err := s.hooks.ListZones() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} + +// handleZoneDelete removes a zone the console owns. +func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) { + if s.hooks.DeleteZone == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + zone := r.URL.Query().Get("zone") + if zone == "" { + writeError(w, http.StatusBadRequest, "zone is required") + return + } + if err := s.hooks.DeleteZone(r.Context(), zone); err != nil { + writeError(w, zoneWriteStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone}) +} + +// zoneWriteStatus tells a configuration problem from a missing record: a +// console that is not allowed to write zone data is a 400 with something the +// operator can fix, while a name that is not there is a 404. +func zoneWriteStatus(err error) int { + if errors.Is(err, localzone.ErrNotManaged) { + return http.StatusBadRequest + } + return http.StatusNotFound +} + +// handleZoneRecordAdd adds one record to a zone. +func (s *Server) handleZoneRecordAdd(w http.ResponseWriter, r *http.Request) { + if s.hooks.AddZoneRecord == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + var in ZoneRecordInput + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if msg := validateZoneRecord(in); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + v, err := s.hooks.AddZoneRecord(r.Context(), in) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} + +// handleZoneRecordPut replaces the RRset of one name and type. +func (s *Server) handleZoneRecordPut(w http.ResponseWriter, r *http.Request) { + if s.hooks.PutZoneRecord == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + var in ZoneRecordInput + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if msg := validateZoneRecord(in); msg != "" { + writeError(w, http.StatusBadRequest, msg) + return + } + v, err := s.hooks.PutZoneRecord(r.Context(), in) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} + +// handleZoneRecordDelete removes a record, or its whole RRset when no value is +// named. +func (s *Server) handleZoneRecordDelete(w http.ResponseWriter, r *http.Request) { + if s.hooks.DeleteZoneRecord == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + zone := r.URL.Query().Get("zone") + name := r.URL.Query().Get("name") + rrtype := r.URL.Query().Get("type") + if zone == "" || name == "" || rrtype == "" { + writeError(w, http.StatusBadRequest, "zone, name and type are required") + return + } + if err := s.hooks.DeleteZoneRecord(r.Context(), zone, name, rrtype, r.URL.Query().Get("value")); err != nil { + writeError(w, zoneWriteStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name, "type": rrtype}) +} + +// handleZoneDelegationAdd delegates a name to other servers. +func (s *Server) handleZoneDelegationAdd(w http.ResponseWriter, r *http.Request) { + if s.hooks.AddZoneDelegation == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + var in ZoneDelegationInput + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if strings.TrimSpace(in.Zone) == "" { + writeError(w, http.StatusBadRequest, "zone is required") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if len(in.Targets) == 0 { + writeError(w, http.StatusBadRequest, "a delegation needs at least one NS target") + return + } + v, err := s.hooks.AddZoneDelegation(r.Context(), in) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, v) +} + +// handleZoneDelegationDelete removes a cut. +func (s *Server) handleZoneDelegationDelete(w http.ResponseWriter, r *http.Request) { + if s.hooks.DeleteZoneDelegation == nil { + writeError(w, http.StatusNotImplemented, "local zones not available") + return + } + zone := r.URL.Query().Get("zone") + name := r.URL.Query().Get("name") + if zone == "" || name == "" { + writeError(w, http.StatusBadRequest, "zone and name are required") + return + } + if err := s.hooks.DeleteZoneDelegation(r.Context(), zone, name); err != nil { + writeError(w, zoneWriteStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name}) +} + +// validateZoneRecord rejects an obviously incomplete record here so the +// console gets a message about the field rather than one about the zone file. +func validateZoneRecord(in ZoneRecordInput) string { + if strings.TrimSpace(in.Zone) == "" { + return "zone is required" + } + if strings.TrimSpace(in.Name) == "" { + return "name is required" + } + if strings.TrimSpace(in.Type) == "" { + return "type is required" + } + if strings.TrimSpace(in.Value) == "" { + return "value is required" + } + return "" +} diff --git a/internal/api/zones_test.go b/internal/api/zones_test.go new file mode 100644 index 0000000..20c152a --- /dev/null +++ b/internal/api/zones_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "net/http" + "testing" +) + +// The zone routes answer 501 rather than 404 when nothing is wired up behind +// them: a missing route reads as "this build has no zone support", which is a +// different thing from "this embedder owns no zones". The fake's hooks are +// installed on the live server, so clearing them is enough. +func TestZoneRoutesWithoutHooks(t *testing.T) { + cases := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/api/zones", ""}, + {http.MethodPost, "/api/zones/records", `{"zone":"example.com.","name":"a","type":"A","value":"10.0.0.1"}`}, + {http.MethodPut, "/api/zones/records", `{"zone":"example.com.","name":"a","type":"A","value":"10.0.0.1"}`}, + {http.MethodDelete, "/api/zones/records?zone=example.com.&name=a&type=A", ""}, + {http.MethodPost, "/api/zones/delegations", `{"zone":"example.com.","name":"sub","targets":["ns1.sub.example.com."]}`}, + {http.MethodDelete, "/api/zones/delegations?zone=example.com.&name=sub.example.com.", ""}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + h := newHarness(t, nil) + h.srv.hooks.ListZones = nil + h.srv.hooks.AddZoneRecord = nil + h.srv.hooks.PutZoneRecord = nil + h.srv.hooks.DeleteZoneRecord = nil + h.srv.hooks.AddZoneDelegation = nil + h.srv.hooks.DeleteZoneDelegation = nil + res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key") + if res.status != http.StatusNotImplemented { + t.Errorf("%s %s = %d, want 501: %s", tc.method, tc.path, res.status, res.body) + } + }) + } +} + +// An incomplete record is refused with a message about the field, before the +// write path is reached: the console shows the error verbatim. +func TestZoneRecordValidation(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"no zone", `{"name":"a.example.com.","type":"A","value":"10.0.0.1"}`, "zone is required"}, + {"no name", `{"zone":"example.com.","type":"A","value":"10.0.0.1"}`, "name is required"}, + {"no type", `{"zone":"example.com.","name":"a","value":"10.0.0.1"}`, "type is required"}, + {"no value", `{"zone":"example.com.","name":"a","type":"A"}`, "value is required"}, + {"malformed", `{`, "malformed request body"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + res := h.do(http.MethodPost, "/api/zones/records", tc.body, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Fatalf("POST /api/zones/records = %d, want 400: %s", res.status, res.body) + } + if got := res.json(t)["error"]; got != tc.want { + t.Errorf("error = %v, want %q", got, tc.want) + } + if h.fake.saw("AddZoneRecord") { + t.Error("an invalid record reached the write path") + } + }) + } +} + +func TestZoneDelegationValidation(t *testing.T) { + cases := []struct { + name string + path string + body string + want string + }{ + {"no zone", "/api/zones/delegations", `{"name":"sub","targets":["ns1.sub.example.com."]}`, "zone is required"}, + {"no name", "/api/zones/delegations", `{"zone":"example.com.","targets":["ns1.sub.example.com."]}`, "name is required"}, + {"no targets", "/api/zones/delegations", `{"zone":"example.com.","name":"sub"}`, "a delegation needs at least one NS target"}, + {"delete without params", "/api/zones/delegations", "", "zone and name are required"}, + {"record delete without params", "/api/zones/records", "", "zone, name and type are required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + method := http.MethodPost + if tc.body == "" { + method = http.MethodDelete + } + res := h.do(method, tc.path, tc.body, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Fatalf("%s %s = %d, want 400: %s", method, tc.path, res.status, res.body) + } + if got := res.json(t)["error"]; got != tc.want { + t.Errorf("error = %v, want %q", got, tc.want) + } + }) + } +} + +// A hook error is a 404 on the delete routes and a 400 on the writes, the same +// split the domain routes use: a delete that matched nothing is a stale +// console, while a rejected record is bad input. +func TestZoneHookErrorsAreReported(t *testing.T) { + t.Run("add", func(t *testing.T) { + h := newHarness(t, nil) + setFail(h, "AddZoneRecord", errHook) + res := h.do(http.MethodPost, "/api/zones/records", `{"zone":"example.com.","name":"a","type":"A","value":"10.0.0.1"}`, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Errorf("POST = %d, want 400: %s", res.status, res.body) + } + }) + t.Run("delete", func(t *testing.T) { + h := newHarness(t, nil) + setFail(h, "DeleteZoneRecord", errHook) + res := h.do(http.MethodDelete, "/api/zones/records?zone=example.com.&name=a&type=A", "", "X-API-Key: rw-key") + if res.status != http.StatusNotFound { + t.Errorf("DELETE = %d, want 404: %s", res.status, res.body) + } + }) + t.Run("list", func(t *testing.T) { + h := newHarness(t, nil) + setFail(h, "ListZones", errHook) + res := h.do(http.MethodGet, "/api/zones", "", "X-API-Key: rw-key") + if res.status != http.StatusInternalServerError { + t.Errorf("GET = %d, want 500: %s", res.status, res.body) + } + }) +} + +// A read-only key may look at the zones and may not change them. +func TestZoneWritesNeedAWriteKey(t *testing.T) { + h := newHarness(t, nil) + if res := h.do(http.MethodGet, "/api/zones", "", "X-API-Key: ro-key"); res.status != http.StatusOK { + t.Errorf("read with a read-only key = %d, want 200: %s", res.status, res.body) + } + writes := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/api/zones/records", `{"zone":"example.com.","name":"a","type":"A","value":"10.0.0.1"}`}, + {http.MethodPut, "/api/zones/records", `{"zone":"example.com.","name":"a","type":"A","value":"10.0.0.1"}`}, + {http.MethodDelete, "/api/zones/records?zone=example.com.&name=a&type=A", ""}, + {http.MethodPost, "/api/zones/delegations", `{"zone":"example.com.","name":"sub","targets":["ns1.sub.example.com."]}`}, + {http.MethodDelete, "/api/zones/delegations?zone=example.com.&name=sub.example.com.", ""}, + } + for _, w := range writes { + res := h.do(w.method, w.path, w.body, "X-API-Key: ro-key") + if res.status != http.StatusForbidden { + t.Errorf("%s %s with a read-only key = %d, want 403: %s", w.method, w.path, res.status, res.body) + } + } +} diff --git a/internal/app/app.go b/internal/app/app.go index ec4ac05..6dbf14d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,20 +56,25 @@ type App struct { cfg *config.Store ver string - blocks *blocklist.Manager - cache *cache.Manager - cstore *cache.Store - logger *qlog.Logger - aclH *acl.Holder - local *localzone.Holder - zones *kubezones.Client - pool *resolver.Pool - reverse *engine.ReverseCache - engine *engine.Engine - dns *server.Server - api *api.Server - cl *cluster.Cluster - series *seriesStore + blocks *blocklist.Manager + cache *cache.Manager + cstore *cache.Store + logger *qlog.Logger + aclH *acl.Holder + local *localzone.Holder + // zones is the Kubernetes-sourced zone client. + zones *kubezones.Client + // managedZones is the console-managed zone directory: the only zone data + // the admin API may rewrite. An empty directory is what makes the + // console's Zones page read-only. + managedZones *localzone.Managed + pool *resolver.Pool + reverse *engine.ReverseCache + engine *engine.Engine + dns *server.Server + api *api.Server + cl *cluster.Cluster + series *seriesStore apiServers []*http.Server apiLn []net.Listener @@ -321,26 +326,36 @@ func (a *App) startBlocklist() { // startLocalZones compiles the local master files. func (a *App) startLocalZones(cfg *config.Config) { a.local = localzone.NewHolder(nil) - if len(cfg.Block.LocalZones) == 0 { - return - } + a.managedZones = localzone.NewManaged(cfg.AbsPath(cfg.Block.ManagedZones)) a.reloadLocalZones() } +// managedZoneDir is the directory the console may write zone files into, or +// "" when the console is read-only. +func (a *App) managedZoneDir() string { return a.managedZones.Dir() } + // reloadLocalZones recompiles the local zone store. A failure keeps the // previous store: serving stale local data beats serving nothing. func (a *App) reloadLocalZones() { a.zoneReloadMu.Lock() defer a.zoneReloadMu.Unlock() cfg := a.cfg.Get() - if len(cfg.Block.LocalZones) == 0 { - a.local.Set(nil) - return - } - paths := make([]string, 0, len(cfg.Block.LocalZones)) + paths := make([]string, 0, len(cfg.Block.LocalZones)+1) for _, p := range cfg.Block.LocalZones { paths = append(paths, cfg.AbsPath(p)) } + // The managed directory is a zone source like any other, so a zone the + // console created is served by the same store. It is only added when it + // exists: the loader stats every path it is given, and a directory the + // console has not written to yet is not a broken configuration. + if a.managedZones.Exists() { + paths = append(paths, a.managedZones.Dir()) + } + if len(paths) == 0 { + a.local.Set(nil) + metrics.SetLocalRecords(0) + return + } store, err := localzone.Load(paths, localzone.Options{}) if err != nil { a.markDegraded("local zones: " + err.Error()) diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index ff13996..17aa5cd 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -5,10 +5,13 @@ import ( "encoding/json" "io" "net/http" + "path/filepath" "sort" "strconv" "strings" "testing" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // The contract tests in this file exist because the console's TypeScript types @@ -550,6 +553,63 @@ func TestRemainingEndpointKeySets(t *testing.T) { } // quote renders a Go string as a JSON string literal. +// TestZoneEndpointKeySets pins the zone list and the records inside it. +// +// The zone list is the one shape on this side of the API that a test cannot +// assert from an empty list: an envelope proves nothing about a record, and a +// record is the thing the console renders row by row. So this builds a zone +// through the write endpoint, delegates a name in it, and pins every level. +func TestZoneEndpointKeySets(t *testing.T) { + managed := filepath.Join(t.TempDir(), "zones") + h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed }) + + write := func(t *testing.T, method, path, body string) { + t.Helper() + code, out := h.do(method, path, body) + if code != http.StatusOK { + t.Fatalf("%s %s = %d: %s", method, path, code, out) + } + } + // A zone needs a server list before it can hold anything, and a + // delegation is the other way to start one. + write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`) + write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`) + write(t, http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`) + + t.Run("zones", func(t *testing.T) { + code, body := h.get("/api/zones") + if code != http.StatusOK { + t.Fatalf("GET /api/zones = %d: %s", code, body) + } + requireKeys(t, "GET /api/zones", keysOf(t, body), + wantKeys("built_at", "error", "files", "managed_dir", "records", "types", "writable", "zones")) + }) + t.Run("zone", func(t *testing.T) { + code, body := h.get("/api/zones") + if code != http.StatusOK { + t.Fatalf("GET /api/zones = %d: %s", code, body) + } + requireKeys(t, "GET /api/zones zones[]", keysOfField(t, body, "zones", "0"), + wantKeys("default_ttl", "delegations", "files", "managed", "origin", "record_count", "records", "serial", "soa")) + }) + t.Run("record", func(t *testing.T) { + code, body := h.get("/api/zones") + if code != http.StatusOK { + t.Fatalf("GET /api/zones = %d: %s", code, body) + } + requireKeys(t, "GET /api/zones zones[].records[]", keysOfField(t, body, "zones", "0", "records", "0"), + wantKeys("delegation", "managed", "name", "source", "ttl", "type", "value", "wildcard")) + }) + t.Run("delegation", func(t *testing.T) { + code, body := h.get("/api/zones") + if code != http.StatusOK { + t.Fatalf("GET /api/zones = %d: %s", code, body) + } + requireKeys(t, "GET /api/zones zones[].delegations[]", keysOfField(t, body, "zones", "0", "delegations", "0"), + wantKeys("managed", "name", "targets")) + }) +} + func quote(s string) string { b, err := json.Marshal(s) if err != nil { diff --git a/internal/app/hooks.go b/internal/app/hooks.go index 9514c77..71dcea1 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -23,36 +23,43 @@ import ( // components. Everything the UI can read or change goes through here. func (a *App) hooks() api.Hooks { return api.Hooks{ - Store: a.cfg, - Logger: a.log, - Version: a.ver, - StartedAt: a.startedAt, - Dashboard: a.dashboard, - Status: a.status, - Health: a.health, - BlocklistState: a.blocklistState, - BlocklistStats: a.blocklistStats, - RefreshBlocklists: a.refreshBlocklists, - ToggleBlocklist: a.toggleBlocklist, - DomainCheck: a.domainCheck, - ListDomains: a.listDomains, - AddDomain: a.addDomain, - DeleteDomain: a.deleteDomain, - LocalZones: a.listLocalZones, - SaveLocalZone: a.saveLocalZone, - DeleteLocalZone: a.deleteLocalZone, - GetACL: a.getACL, - PutACL: a.putACL, - GetUpstream: a.getUpstreams, - PutUpstream: a.putUpstream, - SearchLog: a.searchLog, - LogStats: a.logStats, - SubscribeLog: a.subscribeLog, - ClearLog: a.clearLog, - CacheStats: a.cacheStats, - CacheTop: a.cacheTop, - FlushCache: a.flushCache, - Flush: a.flush, + Store: a.cfg, + Logger: a.log, + Version: a.ver, + StartedAt: a.startedAt, + Dashboard: a.dashboard, + Status: a.status, + Health: a.health, + BlocklistState: a.blocklistState, + BlocklistStats: a.blocklistStats, + RefreshBlocklists: a.refreshBlocklists, + ToggleBlocklist: a.toggleBlocklist, + DomainCheck: a.domainCheck, + ListDomains: a.listDomains, + AddDomain: a.addDomain, + DeleteDomain: a.deleteDomain, + ListZones: a.listZones, + AddZoneRecord: a.addZoneRecord, + PutZoneRecord: a.putZoneRecord, + DeleteZoneRecord: a.deleteZoneRecord, + DeleteZone: a.deleteZone, + AddZoneDelegation: a.addZoneDelegation, + DeleteZoneDelegation: a.deleteZoneDelegation, + LocalZones: a.listLocalZones, + SaveLocalZone: a.saveLocalZone, + DeleteLocalZone: a.deleteLocalZone, + GetACL: a.getACL, + PutACL: a.putACL, + GetUpstream: a.getUpstreams, + PutUpstream: a.putUpstream, + SearchLog: a.searchLog, + LogStats: a.logStats, + SubscribeLog: a.subscribeLog, + ClearLog: a.clearLog, + CacheStats: a.cacheStats, + CacheTop: a.cacheTop, + FlushCache: a.flushCache, + Flush: a.flush, } } diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 8ac6848..2643d21 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strings" "testing" "time" @@ -13,6 +15,245 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) +// TestZoneConsoleRoundTrip is the console's zone page, end to end: a record +// added through the admin API is answered by this resolver on the next query, +// with no reload and no restart, because a zone the console created is +// compiled into the same store the operator's master files go into. +func TestZoneConsoleRoundTrip(t *testing.T) { + managed := filepath.Join(t.TempDir(), "zones") + h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed }) + + do := func(method, path, body string) (int, string) { + t.Helper() + code, out := h.do(method, path, body) + return code, string(out) + } + mustWrite := func(method, path, body string) string { + t.Helper() + code, out := do(method, path, body) + if code != http.StatusOK { + t.Fatalf("%s %s = %d: %s", method, path, code, out) + } + return out + } + + // Before anything is written the page is empty but honest: no zones, the + // managed directory named, and the types the API accepts so the console + // builds its picker from the server. + _, body := h.get("/api/zones") + var empty zonesPayload + if err := json.Unmarshal(body, &empty); err != nil { + t.Fatalf("zones: %v: %s", err, body) + } + // The operator's own zone is listed before the console has written + // anything, and it is not the console's to edit. + if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." { + t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones) + } + if empty.Zones[0].Managed { + t.Error("the operator's zone is reported as managed") + } + if empty.ManagedDir != managed { + t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed) + } + if !empty.Writable { + t.Error("writable = false, but the harness's temp dir is writable") + } + if len(empty.Types) == 0 { + t.Error("types is empty, so the console has no record types to offer") + } + + // A zone starts with a server list: without one it cannot say NXDOMAIN, + // and the writer refuses to invent it. + mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`) + mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`) + + m := h.query("www.console.test", dns.TypeA) + if len(m.Answer) != 1 { + t.Fatalf("query www.console.test = %v, want one answer", m) + } + a, ok := m.Answer[0].(*dns.A) + if !ok || a.A.String() != "10.9.9.9" { + t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0]) + } + if m.Authoritative != true { + t.Error("the local answer is not authoritative") + } + + // The file is on disk where the operator can read it, and it is a zone + // file rather than a JSON dump. + raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone")) + if err != nil { + t.Fatalf("read the managed zone file: %v", err) + } + for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} { + if !strings.Contains(string(raw), want) { + t.Errorf("the zone file is missing %q:\n%s", want, raw) + } + } + + // A delegation is a cut: the parent stops answering below it and the + // query goes upstream, which is the whole point of making one. + mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`) + m = h.query("www.sub.console.test", dns.TypeA) + if len(m.Answer) != 1 { + t.Fatalf("query below the cut = %v, want the upstream answer", m) + } + if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" { + t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0]) + } + // And the cut itself is answerable, or no resolver could ever learn it. + m = h.query("sub.console.test", dns.TypeNS) + if len(m.Answer) != 1 { + t.Errorf("NS at the cut = %v, want the delegation itself", m) + } + + // A record below a cut is dead data, so it is refused rather than + // written and silently never answered. + if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest { + t.Errorf("a record below a delegation = %d, want 400", code) + } + + // Deleting the record leaves the zone authoritative, and a zone that + // exists says NXDOMAIN for the names it does not hold. That is what + // creating a zone means, and it is why deleting a zone is a separate + // action below rather than a side effect of deleting a record. + mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "") + m = h.query("www.console.test", dns.TypeA) + if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 { + t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m) + } + if m.Authoritative != true { + t.Error("the NXDOMAIN is not authoritative, so the zone is not serving") + } + + // Deleting something that is not there is a 404, not a 500. + if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound { + t.Errorf("deleting an absent record = %d, want 404", code) + } + + // Removing the zone is what hands the name back to upstream, and the + // cached NXDOMAIN goes with it. + mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "") + if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) { + t.Errorf("the managed zone file is still there (stat err = %v)", err) + } + m = h.query("www.console.test", dns.TypeA) + if len(m.Answer) != 1 { + t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m) + } + if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" { + t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0]) + } + // And the zone is gone from the page. + _, body = h.get("/api/zones") + var gone zonesPayload + if err := json.Unmarshal(body, &gone); err != nil { + t.Fatalf("zones: %v: %s", err, body) + } + for _, zone := range gone.Zones { + if zone.Origin == "console.test." { + t.Errorf("the deleted zone is still listed: %+v", zone) + } + } + + // An operator's zone is not the console's to delete. + if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound { + t.Errorf("deleting the operator's zone = %d, want 404", code) + } + + // The operator's master file is still theirs: it is listed, and it is not + // reported as console-managed. + var sawLocal bool + for _, zone := range gone.Zones { + if zone.Origin != "local." { + continue + } + sawLocal = true + if zone.Managed { + t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it") + } + } + if !sawLocal { + t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones) + } +} + +// zonesPayload is the slice of GET /api/zones this test reads. It is declared +// here rather than imported so the assertions name the fields the console +// depends on; the contract test in this package pins the whole key set. +type zonesPayload struct { + Zones []struct { + Origin string `json:"origin"` + Managed bool `json:"managed"` + Serial uint32 `json:"serial"` + Records []any `json:"records"` + RecordCount int `json:"record_count"` + Delegations []struct { + Name string `json:"name"` + Targets []string `json:"targets"` + Managed bool `json:"managed"` + } `json:"delegations"` + } `json:"zones"` + Files []string `json:"files"` + ManagedDir string `json:"managed_dir"` + Writable bool `json:"writable"` + Types []string `json:"types"` + Records int `json:"records"` +} + +// TestZoneWritesNeedAManagedDirectory pins the read-only default: a +// deployment that never sets blocklist.managed_zones can browse its local zones +// and cannot have one rewritten behind its back. +func TestZoneWritesNeedAManagedDirectory(t *testing.T) { + h := newHarness(t, nil) + code, body := h.get("/api/zones") + if code != http.StatusOK { + t.Fatalf("GET /api/zones = %d: %s", code, body) + } + var zones zonesPayload + if err := json.Unmarshal(body, &zones); err != nil { + t.Fatalf("zones: %v: %s", err, body) + } + if zones.Writable { + t.Error("writable = true with no managed zone directory configured") + } + if zones.ManagedDir != "" { + t.Errorf("managed_dir = %q, want empty", zones.ManagedDir) + } + if len(zones.Zones) == 0 { + t.Error("the operator's local zone is missing, so the page would be blank rather than read-only") + } + for _, zone := range zones.Zones { + if zone.Managed { + t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin) + } + } + // Every write route refuses, and says what to configure. + writes := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`}, + {http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`}, + {http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""}, + {http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`}, + {http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""}, + {http.MethodDelete, "/api/zones?zone=console.test.", ""}, + } + for _, w := range writes { + code, out := h.do(w.method, w.path, w.body) + if code != http.StatusBadRequest { + t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out) + continue + } + if !strings.Contains(string(out), "managed_zones") { + t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out) + } + } +} + // TestResolveUpstream covers the happy path: a name nobody blocks is forwarded // to the upstream and the answer is cached. func TestResolveUpstream(t *testing.T) { diff --git a/internal/app/zones.go b/internal/app/zones.go new file mode 100644 index 0000000..ff5b1ac --- /dev/null +++ b/internal/app/zones.go @@ -0,0 +1,248 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/api" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" +) + +// The local-zone write path. +// +// Everything the console changes lives in the managed zone directory +// (blocklist.managed_zones), one .zone per zone. The operator's own master +// files in blocklist.local_zones are read and reported, never rewritten: a +// deployment that never configures a managed directory gets a read-only zone +// browser, and one that does gets a console whose edits land in a file it +// names and owns. +// +// Every change follows the same four steps: write the file, recompile the +// whole store, drop the cached answers, report. A change that does not +// recompile is a change the query path is not serving, so the store is only +// swapped after the new file parsed, and a failure keeps the previous store. + +// listZones renders the compiled store for the console. +func (a *App) listZones() (any, error) { + dir := a.managedZoneDir() + store := a.local.Get() + // The slices are never nil: the console reads lengths and maps without + // a null check, and an empty list is a list. + res := api.ZonesResponse{ + Zones: []api.Zone{}, + Files: []string{}, + ManagedDir: dir, + Writable: a.managedZones.Writable(), + Types: localzone.EditableTypes(), + } + if store != nil { + res.Files = store.Files() + res.BuiltAt = store.BuiltAt() + for _, zone := range store.SnapshotAll(dir) { + res.Zones = append(res.Zones, *zone) + res.Records += zone.RecordCount + } + } + res.Error = a.localZoneError() + return res, nil +} + +// addZoneRecord adds one record to a zone. +func (a *App) addZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) { + origin, err := a.zoneOrigin(in.Zone) + if err != nil { + return nil, err + } + serial, err := a.managedZones.Add(origin, localzone.Record{ + Name: in.Name, + Type: in.Type, + TTL: in.TTL, + Value: in.Value, + }, a.zoneDefaults(origin)) + if err != nil { + return nil, err + } + a.afterZoneWrite(origin, "add", serial) + return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil +} + +// putZoneRecord replaces the whole RRset of one name and type. +func (a *App) putZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) { + origin, err := a.zoneOrigin(in.Zone) + if err != nil { + return nil, err + } + serial, err := a.managedZones.Put(origin, localzone.Record{ + Name: in.Name, + Type: in.Type, + TTL: in.TTL, + Value: in.Value, + }, a.zoneDefaults(origin)) + if err != nil { + return nil, err + } + a.afterZoneWrite(origin, "replace", serial) + return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil +} + +// deleteZoneRecord removes a record, or its whole RRset when value is empty. +func (a *App) deleteZoneRecord(_ context.Context, zone, name, rrtype, value string) error { + origin, err := a.zoneOrigin(zone) + if err != nil { + return err + } + serial, removed, err := a.managedZones.Delete(origin, name, rrtype, value) + if err != nil { + return err + } + a.afterZoneWrite(origin, "delete", serial) + if !removed { + return fmt.Errorf("no managed record matches %s %s in %s", name, rrtype, origin) + } + return nil +} + +// addZoneDelegation hands a name below an apex to other servers. +func (a *App) addZoneDelegation(_ context.Context, in api.ZoneDelegationInput) (any, error) { + origin, err := a.zoneOrigin(in.Zone) + if err != nil { + return nil, err + } + targets := make([]string, 0, len(in.Targets)) + for _, t := range in.Targets { + if t = strings.TrimSpace(t); t != "" { + targets = append(targets, t) + } + } + if len(targets) == 0 { + return nil, errors.New("a delegation needs at least one NS target") + } + // A brand new zone has no NS set of its own, so the delegation's targets + // are also the zone's. An existing file ignores this. + def := a.zoneDefaults(origin) + def.NS = targets + if def.TTL == 0 { + def.TTL = in.TTL + } + serial, err := a.managedZones.SetDelegation(origin, in.Name, targets, in.TTL, def) + if err != nil { + return nil, err + } + a.afterZoneWrite(origin, "delegate", serial) + return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil +} + +// deleteZoneDelegation removes a cut. +func (a *App) deleteZoneDelegation(_ context.Context, zone, name string) error { + origin, err := a.zoneOrigin(zone) + if err != nil { + return err + } + serial, removed, err := a.managedZones.RemoveDelegation(origin, name) + if err != nil { + return err + } + a.afterZoneWrite(origin, "undelegate", serial) + if !removed { + return fmt.Errorf("no delegation for %s in %s", name, origin) + } + return nil +} + +// zoneOrigin canonicalises the apex a write names and refuses to go near the +// disk when the console has no managed directory. +func (a *App) zoneOrigin(zone string) (string, error) { + origin, err := localzone.NormalizeOrigin(zone) + if err != nil { + return "", err + } + if err := a.managedZones.Check(); err != nil { + return "", err + } + return origin, nil +} + +// deleteZone removes a zone the console created, which is the only way a name +// goes back to upstream once the console has made a zone authoritative for +// it: a zone that exists answers NXDOMAIN for everything it does not hold. +func (a *App) deleteZone(_ context.Context, zone string) error { + origin, err := a.zoneOrigin(zone) + if err != nil { + return err + } + // The zone has to be wholly the console's: deleting a managed file that + // shares its apex with an operator's master file would remove none of + // the zone, only half of it. + whole := false + if store := a.local.Get(); store != nil { + if view := store.Snapshot(origin, a.managedZoneDir()); view != nil { + whole = view.Managed + } else { + return fmt.Errorf("no zone %s is loaded", origin) + } + } else { + return fmt.Errorf("no zone %s is loaded", origin) + } + removed, err := a.managedZones.Remove(origin, whole) + if err != nil { + return err + } + if !removed { + return fmt.Errorf("no managed zone file for %s", origin) + } + a.afterZoneWrite(origin, "delete-zone", 0) + return nil +} + +// zoneDefaults seeds a managed file for an apex that has none from the zone +// as it is compiled today, so the console never has to restate a zone's +// server list and never invents one. +func (a *App) zoneDefaults(origin string) localzone.ZoneDefaults { + store := a.local.Get() + if store == nil { + return localzone.ZoneDefaults{} + } + zone := store.Snapshot(origin, a.managedZoneDir()) + if zone == nil { + return localzone.ZoneDefaults{} + } + def := localzone.ZoneDefaults{TTL: zone.DefaultTTL, Soa: zone.Soa} + // The apex NS set follows the zone's existing one, from the operator's + // file if that is where it lives. A managed file that already has its own + // is not asked for defaults at all, so this only ever happens once. + for _, rec := range zone.Records { + if rec.Name == origin && rec.Type == "NS" { + def.NS = append(def.NS, rec.Value) + } + } + return def +} + +// afterZoneWrite is the tail every change shares: recompile, drop the cached +// answers, log. The flush is the blunt half and it is deliberate — a name the +// zone now answers authoritatively may have an upstream answer cached, and +// which names changed is not knowable from here. +func (a *App) afterZoneWrite(origin, action string, serial uint32) { + a.reloadLocalZones() + if a.cache != nil { + a.cache.Flush() + } + records := 0 + if store := a.local.Get(); store != nil { + records = store.Count() + } + a.log.Info("local zone changed", "zone", origin, "action", action, "serial", serial, "records", records) +} + +// localZoneError is the reason the store is stale, or "" when it is current. +// It comes from the degraded list, so the console and /healthz agree. +func (a *App) localZoneError() string { + for _, d := range a.Degraded() { + if rest, ok := strings.CutPrefix(d, "local zones: "); ok { + return rest + } + } + return "" +} diff --git a/internal/config/config.go b/internal/config/config.go index 5ccb98e..0a262df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -308,6 +308,15 @@ type Block struct { // LocalZones are DNS master files (RFC 1035) holding RPZ-style policy // records and/or locally defined zones. LocalZones []string `yaml:"local_zones"` + // ManagedZones is the directory the admin API may write local zone + // files into: one .zone per zone, holding the SOA, the apex NS + // set and whatever records the console created. It is the only zone + // data the console owns — the files in LocalZones are the operator's and + // are reported read-only. + // + // Empty means the console's zone pages are read-only, so a deployment + // that never sets it cannot have zone data rewritten behind its back. + ManagedZones string `yaml:"managed_zones"` // Lists are remote or on-disk sources of block data. Lists []ListSource `yaml:"lists"` // AllowEntries and BlockEntries are inline rules (a list of DNS names or diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 469d985..0025a7e 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -217,9 +217,11 @@ func (e *Engine) applyConfig(cfg *config.Config) { e.aclH.Set(acl.Compile(cfg.ACL)) metrics.SetACLGroups(len(cfg.ACL.Groups)) } - if e.local != nil && len(cfg.Block.LocalZones) > 0 { - // Local zones are loaded asynchronously by the server; here we only - // record that a reload happened. + // Local zones answer the query before the blocklist, so the gate is the + // store itself rather than the configured file list: a zone the console + // created in the managed directory is served whether or not + // blocklist.local_zones names it. + if e.local != nil && (len(cfg.Block.LocalZones) > 0 || e.local.Get().Count() > 0) { metrics.SetLocalRecords(e.local.Get().Count()) } if e.cache != nil { diff --git a/internal/localzone/managed.go b/internal/localzone/managed.go new file mode 100644 index 0000000..4b57276 --- /dev/null +++ b/internal/localzone/managed.go @@ -0,0 +1,833 @@ +package localzone + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" + + "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 +// 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 +// things a console should be able to add to a zone it serves. +var editableTypes = []string{ + "A", "AAAA", "CAA", "CNAME", "DHCID", "HINFO", "HTTPS", "LOC", + "MX", "NAPTR", "NS", "PTR", "SRV", "SPF", "SRV", "SSHFP", "SVCB", + "TXT", "URI", +} + +// EditableTypes is the set of record types the API will write, sorted and +// deduplicated. The console builds its type picker from this, so the two +// cannot drift apart. +func EditableTypes() []string { + seen := make(map[string]bool, len(editableTypes)) + out := make([]string, 0, len(editableTypes)) + for _, t := range editableTypes { + if seen[t] { + continue + } + seen[t] = true + out = append(out, t) + } + sort.Strings(out) + return out +} + +func isEditableType(rrtype string) bool { + for _, t := range EditableTypes() { + if t == rrtype { + return true + } + } + 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. +type ZoneDefaults struct { + // TTL is the $TTL of a new file; 0 means DefaultZoneTTL. + TTL uint32 + // Soa is an existing zone's SOA rdata (absolute names), or "" to + // synthesise one from the first NS target. + Soa string + // NS are the apex NS targets of a new file. + 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 + ttl uint32 + soa *dns.SOA + recs []Record +} + +// newFileState builds a file for an apex that has none: an existing zone's +// SOA and NS set when the caller supplied them, else a synthesised SOA whose +// primary is the first NS target. +func newFileState(origin string, def ZoneDefaults) (*fileState, error) { + ttl := def.TTL + if ttl == 0 { + ttl = DefaultZoneTTL + } + f := &fileState{origin: origin, ttl: ttl} + if def.Soa != "" { + rr, err := dns.NewRR(origin + " " + zonefile.FormatTTL(ttl) + " IN SOA " + def.Soa) + if err != nil { + return nil, fmt.Errorf("seed SOA: %w", err) + } + soa, ok := rr.(*dns.SOA) + if !ok { + return nil, fmt.Errorf("seed SOA is not an SOA record") + } + cp := *soa + f.soa = &cp + } + for _, target := range def.NS { + rec, err := newRecord(origin, origin, "NS", ttl, target) + if err != nil { + return nil, err + } + f.recs = append(f.recs, rec) + } + return f, nil +} + +// finish is the invariant check that runs before anything touches the disk: a +// zone with no SOA cannot answer NXDOMAIN or NODATA (the engine sends a NODATA +// with no authority section instead), and a zone with no apex NS set delegates +// nothing and has no server to name. +func (f *fileState) finish() error { + apex := make([]string, 0, 4) + for _, rec := range f.recs { + if rec.Name == f.origin && rec.Type == "NS" { + apex = append(apex, rec.Value) + } + } + if err := f.checkCuts(); err != nil { + return err + } + if f.soa == nil { + if len(apex) == 0 { + return errors.New("a new zone needs an NS record at the apex, or a delegation to start from") + } + soa, err := synthSOA(f.origin, apex[0], serialFor(time.Now()), f.ttl) + if err != nil { + return err + } + f.soa = soa + } + // The SOA's primary follows the apex NS set. A zone whose SOA names a + // server that is not in its own NS set is a zone that hands out a + // referral no resolver can follow, and the console is what changes that + // set, so the console has to keep the two together. + sort.Strings(apex) + if !slices.Contains(apex, f.soa.Ns) && len(apex) > 0 { + f.soa.Ns = apex[0] + } + f.soa.Serial = nextSerial(f.soa.Serial) + sortRecords(f.recs) + return nil +} + +// checkCuts refuses data that a delegation has taken away. A cut means the +// names under it belong to another server: the store answers them as +// delegated and never looks at a record that lives there, so a record written +// below a cut is dead data that looks alive. The cut's own NS RRset is the +// one thing the parent does serve (RFC 1034 §4.3.3). +func (f *fileState) checkCuts() error { + cuts := make([]string, 0, 4) + for _, rec := range f.recs { + if rec.Type == "NS" && rec.Name != f.origin { + cuts = append(cuts, rec.Name) + } + } + for _, cut := range cuts { + for _, rec := range f.recs { + if rec.Name == cut { + continue + } + if strings.HasSuffix(rec.Name, "."+cut) { + return fmt.Errorf("%s is delegated to another server; delete the delegation to put data back", + rec.Name) + } + } + } + return nil +} + +// render writes the file in the form an operator would have typed: a header, a +// $ORIGIN/$TTL pair, the SOA, then one owner line per name with its records +// indented under it. +func (f *fileState) render() string { + var sb strings.Builder + sb.WriteString(FileHeader) + fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin) + fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl)) + soa := f.soa + fmt.Fprintf(&sb, "@\t%s IN SOA\t%s %s %d %d %d %d %d\n", + zonefile.FormatTTL(soa.Hdr.Ttl), soa.Ns, soa.Mbox, soa.Serial, + soa.Refresh, soa.Retry, soa.Expire, soa.Minttl) + + byName := make(map[string][]Record) + for _, rec := range f.recs { + byName[rec.Name] = append(byName[rec.Name], rec) + } + // One record per line, not the owner-then-indented-records form: this + // project's master-file parser takes a bare owner line as a record with + // no type, and a zone file only loads if its own parser accepts it. + for _, name := range sortedNames(byName) { + for _, rec := range byName[name] { + ttl := rec.TTL + if ttl == 0 { + ttl = f.ttl + } + fmt.Fprintf(&sb, "%-28s %s IN %-5s %s\n", + relativeOwner(name, f.origin), zonefile.FormatTTL(ttl), rec.Type, rec.Value) + } + } + return sb.String() +} + +func sortedNames(byName map[string][]Record) []string { + out := make([]string, 0, len(byName)) + for name := range byName { + out = append(out, name) + } + sort.Strings(out) + 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 + if ttl == 0 { + ttl = DefaultZoneTTL + } + f := &fileState{origin: origin, ttl: ttl} + if z.SOA != nil { + cp := *z.SOA + f.soa = &cp + } + for _, rec := range z.Records { + if _, isSOA := rec.RR.(*dns.SOA); isSOA { + continue + } + rrttl := rec.TTL + if rrttl == 0 { + rrttl = ttl + } + f.recs = append(f.recs, Record{ + Name: rec.Name, + Type: dns.TypeToString[rec.RR.Header().Rrtype], + TTL: rrttl, + Value: rdata(rec.RR), + Wildcard: strings.HasPrefix(rec.Name, "*."), + }) + } + return f +} + +// parseFileState is the reader half of the round-trip check: it parses text +// the writer produced and returns it in the same form the writer held it. +func parseFileState(origin, text string) (*fileState, error) { + z, err := zonefile.Parse(strings.NewReader(text), zonefile.Options{Origin: origin, File: ""}) + if err != nil { + return nil, err + } + return fileStateFromZone(z, origin), nil +} + +// sameRecords compares the records a rendered file parses back to with the +// ones the writer meant to write. A round-trip that loses a record, changes a +// TTL or mangles a name is a writer bug, and it is caught here rather than by +// an operator noticing a zone that stopped answering. +func sameRecords(got, want []Record) error { + if len(got) != len(want) { + return fmt.Errorf("rendered %d records, expected %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + return fmt.Errorf("rendered %+v, expected %+v", got[i], want[i]) + } + } + return nil +} + +// newRecord validates one API record and returns it in canonical form: the +// owner fully qualified, the type uppercased, the RDATA as dns.NewRR parses +// it. Canonicalising at the edge means the store snapshot, the console and +// the file always agree on spelling. +func newRecord(origin, name, rrtype string, ttl uint32, value string) (Record, error) { + owner, err := ownerInZone(origin, name) + if err != nil { + return Record{}, err + } + up := strings.ToUpper(strings.TrimSpace(rrtype)) + if up == "" { + return Record{}, errors.New("record type is required") + } + if !isEditableType(up) { + return Record{}, fmt.Errorf("type %s cannot be written through the API", up) + } + value = strings.TrimSpace(value) + if value == "" { + return Record{}, fmt.Errorf("the %s record needs a value", up) + } + line := owner + " " + zonefile.FormatTTL(ttl) + " IN " + up + " " + value + // The project's own master-file parser is the authority on what a record + // line means: it qualifies an RDATA name written without a trailing dot + // against the origin, which is what an operator typing "ns1" for a CNAME + // in a zone file gets. Parsing here rather than with dns.NewRR is what + // keeps the API's idea of a value and the store's the same value. + z, err := zonefile.Parse(strings.NewReader(line), zonefile.Options{Origin: origin, File: ""}) + if err != nil { + return Record{}, fmt.Errorf("%s %s: %w", owner, up, err) + } + if len(z.Records) != 1 { + return Record{}, fmt.Errorf("%s %s: the line parsed into %d records", owner, up, len(z.Records)) + } + parsed := z.Records[0] + if _, isSOA := parsed.RR.(*dns.SOA); isSOA { + return Record{}, errors.New("SOA is zone metadata, not a record the API writes") + } + if !inZone(parsed.Name, origin) { + return Record{}, fmt.Errorf("%s is outside the zone %s", parsed.Name, origin) + } + return Record{ + Name: parsed.Name, + Type: up, + TTL: parsed.TTL, + Value: rdata(parsed.RR), + Wildcard: strings.HasPrefix(parsed.Name, "*."), + }, nil +} + +// ttlOr picks the record's own TTL, falling back to the zone's. +func ttlOr(rec, zone uint32) uint32 { + if rec != 0 { + return rec + } + return zone +} + +// ownerInZone canonicalises an owner name the way a zone file would: a name +// written without a trailing dot belongs to the zone it is in, so "www" in +// home.example.com is www.home.example.com. — the console asks for a short +// name and a relative one is the common case, not an error. +func ownerInZone(origin, name string) (string, error) { + trimmed := strings.TrimSpace(name) + owner, err := NormalizeName(trimmed) + if err != nil { + return "", fmt.Errorf("owner name: %w", err) + } + if !strings.HasSuffix(trimmed, ".") { + // A relative name: qualify it, unless it already spells the apex or + // something under it (which is what an operator pasting a name out of + // the table above does). + bare := strings.TrimSuffix(strings.TrimPrefix(owner, "*."), ".") + base := strings.TrimSuffix(origin, ".") + if bare != base && !strings.HasSuffix(bare, "."+base) { + owner = dns.Fqdn(bare + "." + base) + if strings.HasPrefix(strings.TrimSpace(name), "*.") { + owner = "*." + owner + } + } + } + if !inZone(owner, origin) { + return "", fmt.Errorf("%s is outside the zone %s", owner, origin) + } + return owner, nil +} + +// NormalizeOrigin canonicalises a zone apex and refuses the ones the writer +// cannot round-trip. +func NormalizeOrigin(in string) (string, error) { + out := strings.ToLower(strings.TrimSpace(dns.Fqdn(strings.TrimSpace(in)))) + if out == "." { + return "", errors.New("the root zone is not editable") + } + if out == "" || !strings.Contains(strings.TrimSuffix(out, "."), ".") { + return "", fmt.Errorf("%q is not a zone apex", in) + } + for _, label := range strings.Split(strings.TrimSuffix(out, "."), ".") { + if label == "" { + return "", fmt.Errorf("%q is not a zone apex", in) + } + } + return out, nil +} + +// NormalizeName canonicalises an owner name, allowing a leftmost "*." wildcard. +func NormalizeName(in string) (string, error) { + s := strings.ToLower(strings.TrimSpace(in)) + if s == "" || s == "." { + return "", errors.New("owner name is required") + } + wild := strings.HasPrefix(s, "*.") + s = strings.TrimPrefix(s, "*.") + full := strings.ToLower(dns.Fqdn(s)) + for _, label := range strings.Split(strings.TrimSuffix(full, "."), ".") { + if label == "" { + return "", fmt.Errorf("%q is not a domain name", in) + } + } + if wild { + full = "*." + full + } + return full, nil +} + +// relativeOwner renders an owner relative to the origin, the way a zone file +// is written. +func relativeOwner(name, origin string) string { + if name == origin { + return "@" + } + suffixed := strings.TrimSuffix(name, ".") + originSuffix := strings.TrimSuffix(origin, ".") + if suffixed == originSuffix { + return "@" + } + if len(suffixed) > len(originSuffix) && strings.HasSuffix(suffixed, "."+originSuffix) { + return suffixed[:len(suffixed)-len(originSuffix)-1] + } + 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 == "" { + return nil, errors.New("an SOA needs a primary nameserver") + } + if !strings.HasSuffix(primary, ".") { + primary = dns.Fqdn(primary) + } + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN SOA %s hostmaster.%s %d 3600 600 604800 %d", + origin, ttl, primary, origin, serial, ttl)) + if err != nil { + return nil, err + } + soa, ok := rr.(*dns.SOA) + if !ok { + return nil, errors.New("built SOA did not parse") + } + return soa, nil +} + +// nextSerial advances a serial by one. A zone with no serial yet gets a +// date-based one, which is what an operator's first edit expects to see. +func nextSerial(current uint32) uint32 { + if current == 0 { + return serialFor(time.Now()) + } + if next, err := zonefile.SerialNext(current, "+1"); err == nil { + return next + } + return current +} + +// serialFor is the YYYYMMDDnn form RFC 1912 §2.2.2 recommends. +func serialFor(t time.Time) uint32 { + day, err := strconv.ParseUint(t.UTC().Format("20060102"), 10, 32) + if err != nil { + return 1 + } + return uint32(day) +} diff --git a/internal/localzone/managed_test.go b/internal/localzone/managed_test.go new file mode 100644 index 0000000..4eb0d20 --- /dev/null +++ b/internal/localzone/managed_test.go @@ -0,0 +1,704 @@ +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 +} diff --git a/internal/localzone/records.go b/internal/localzone/records.go new file mode 100644 index 0000000..307e1f7 --- /dev/null +++ b/internal/localzone/records.go @@ -0,0 +1,300 @@ +package localzone + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/miekg/dns" +) + +// Record is one authoritative record as the API and the console see it. +// +// The compiled store keeps plain dns.RR slices, because that is what the +// query path reads; this presentation form is built on demand for the admin +// API only, so a zone browser can show what the operator actually wrote. +type Record struct { + // Name is the fully-qualified owner, trailing dot included. A wildcard + // owner is reported as written ("*.example.com."). + Name string `json:"name"` + // Type is the RR type, uppercase ("A", "MX", "NS"). + Type string `json:"type"` + // TTL is the record's own TTL in seconds. + TTL uint32 `json:"ttl"` + // Value is the presentation-format RDATA with every domain name + // absolute, so a record read out of the store can be pasted into a new + // zone file unchanged. + Value string `json:"value"` + // Wildcard is true when the owner was written "*.name". + Wildcard bool `json:"wildcard"` + // Delegation is true for an NS record below the apex: the cut that hands + // the names under it to another server. + Delegation bool `json:"delegation"` + // Managed is true when the record came from the console-managed + // directory, the only data the API may rewrite. + Managed bool `json:"managed"` + // Source is the file the record was parsed from. + Source string `json:"source"` +} + +// Key identifies the RRset a record belongs to: owner name plus type. A +// delegation is the NS RRset of one name, so it is the same key. +func (r Record) Key() string { return r.Name + "|" + r.Type } + +// Zone is a compiled zone as the admin API reports it. +type Zone struct { + // Origin is the apex, lowercased and fully qualified. + Origin string `json:"origin"` + // Serial is the SOA serial, 0 when the zone has no SOA. + Serial uint32 `json:"serial"` + // Soa is the zone's start of authority in presentation form, with every + // name absolute, or "" when the zone has no SOA. It is not a record the + // API writes: the writer owns it, and a new managed file is seeded from + // this so a zone keeps the server list it already had. + Soa string `json:"soa"` + // DefaultTTL is the zone's smallest TTL, which is what a record written + // without one inherits. + DefaultTTL uint32 `json:"default_ttl"` + // Records are sorted by owner, then type, then value. + Records []Record `json:"records"` + // Delegations is the subset of Records that is a cut below the apex, + // with the NS targets collected per name. + Delegations []Delegation `json:"delegations"` + // RecordCount is len(Records). + RecordCount int `json:"record_count"` + // Managed is true when every source file of the zone is inside the + // console-managed directory, i.e. the whole zone is API-writable. + Managed bool `json:"managed"` + // Files are the sources this zone was compiled from. + Files []string `json:"files"` +} + +// Delegation is a cut in the zone: a name below the apex whose NS records +// hand it, and everything under it, to other servers. +type Delegation struct { + // Name is the delegated name, fully qualified. + Name string `json:"name"` + // Targets are the NS targets, lowercased and fully qualified. + Targets []string `json:"targets"` + // Managed is true when the delegation came from the managed directory. + Managed bool `json:"managed"` +} + +// Snapshot renders one zone for the API, or nil when the store holds no such +// zone. Records from every source file are included and each one carries the +// file it came from, because the console has to be able to tell an +// operator's read-only master file apart from data it may rewrite. +func (s *Store) Snapshot(origin, managedDir string) *Zone { + if s == nil { + return nil + } + z, ok := s.zones[strings.ToLower(dns.Fqdn(origin))] + if !ok { + return nil + } + view := &Zone{ + Origin: z.origin, + DefaultTTL: z.minTTL, + Files: append([]string(nil), z.sources...), + Managed: true, + } + if z.soa != nil { + view.Serial = z.soa.Serial + view.Soa = fmt.Sprintf("%s %s %d %d %d %d %d", z.soa.Ns, z.soa.Mbox, + z.soa.Serial, z.soa.Refresh, z.soa.Retry, z.soa.Expire, z.soa.Minttl) + } + for k, rrs := range z.exact { + name, rrtype, _ := strings.Cut(k, "|") + for _, rr := range rrs { + view.Records = append(view.Records, z.record(rr, name, rrtype, k, managedDir)) + } + } + for name, byType := range z.wild { + for rrtype, rrs := range byType { + rrtypeStr := dns.TypeToString[rrtype] + k := wildKey(name, rrtypeStr) + for _, rr := range rrs { + view.Records = append(view.Records, z.record(rr, "*."+name, rrtypeStr, k, managedDir)) + } + } + } + for _, rec := range view.Records { + if !rec.Managed { + view.Managed = false + break + } + } + sortRecords(view.Records) + view.RecordCount = len(view.Records) + view.Delegations = delegations(view.Records) + return view +} + +// SnapshotAll renders every compiled zone, sorted by apex. +func (s *Store) SnapshotAll(managedDir string) []*Zone { + if s == nil { + return nil + } + out := make([]*Zone, 0, len(s.zones)) + for _, origin := range s.Zones() { + if z := s.Snapshot(origin, managedDir); z != nil { + out = append(out, z) + } + } + return out +} + +// record builds the presentation form of one compiled record. key is the +// index it is stored under, which is where its source file is recorded. +func (z *zone) record(rr dns.RR, name, rrtype, key, managedDir string) Record { + rec := Record{ + Name: name, + Type: rrtype, + TTL: rr.Header().Ttl, + Value: rdata(rr), + Wildcard: strings.HasPrefix(name, "*."), + Source: z.source(key), + } + // The apex NS set is the zone's own server list, not a cut; only an NS + // record below the apex hands names to another server. + rec.Delegation = rec.Type == "NS" && !rec.Wildcard && name != z.origin + rec.Managed = inDir(rec.Source, managedDir) + return rec +} + +// delegations collects the cuts out of a rendered record list. +func delegations(recs []Record) []Delegation { + byName := make(map[string]*Delegation) + var order []string + for _, rec := range recs { + if !rec.Delegation { + continue + } + d, ok := byName[rec.Name] + if !ok { + d = &Delegation{Name: rec.Name, Managed: rec.Managed} + byName[rec.Name] = d + order = append(order, rec.Name) + } + d.Targets = append(d.Targets, rec.Value) + } + sort.Strings(order) + out := make([]Delegation, 0, len(order)) + for _, name := range order { + d := byName[name] + sort.Strings(d.Targets) + d.Targets = dedupe(d.Targets) + out = append(out, *d) + } + return out +} + +// rdata renders the RDATA of a record with domain names absolute. The generic +// fallback is the RR's own String(), which is right for every type not listed +// here. +func rdata(rr dns.RR) string { + switch v := rr.(type) { + case *dns.A: + return v.A.String() + case *dns.AAAA: + return v.AAAA.String() + case *dns.CNAME: + return v.Target + case *dns.NS: + return v.Ns + case *dns.PTR: + return v.Ptr + case *dns.MX: + return utoa(uint64(v.Preference)) + " " + v.Mx + case *dns.SRV: + return utoa(uint64(v.Priority)) + " " + utoa(uint64(v.Weight)) + " " + + utoa(uint64(v.Port)) + " " + v.Target + case *dns.TXT: + return quoteStrings(v.Txt) + case *dns.CAA: + return utoa(uint64(v.Flag)) + " " + v.Tag + " " + quote(v.Value) + default: + // RR.String() is the whole line, owner and TTL included. The RDATA + // is everything after the type, which is the fourth tab-separated + // field. + fields := strings.Split(rr.String(), "\t") + if len(fields) < 5 { + return strings.TrimSpace(rr.String()) + } + return strings.Join(fields[4:], " ") + } +} + +func utoa(v uint64) string { return strconv.FormatUint(v, 10) } + +func quoteStrings(in []string) string { + out := make([]string, 0, len(in)) + for _, s := range in { + out = append(out, quote(s)) + } + return strings.Join(out, " ") +} + +func quote(s string) string { + var sb strings.Builder + sb.Grow(len(s) + 2) + sb.WriteByte('"') + for i := 0; i < len(s); i++ { + if c := s[i]; c == '"' || c == '\\' { + sb.WriteByte('\\') + } + sb.WriteByte(s[i]) + } + sb.WriteByte('"') + return sb.String() +} + +// sortRecords orders a rendered zone by owner, then type, then value, so the +// console's table is stable and the writer's output is deterministic. +func sortRecords(recs []Record) { + sort.Slice(recs, func(i, j int) bool { + if recs[i].Name != recs[j].Name { + return recs[i].Name < recs[j].Name + } + if recs[i].Type != recs[j].Type { + return recs[i].Type < recs[j].Type + } + return recs[i].Value < recs[j].Value + }) +} + +func dedupe(in []string) []string { + if len(in) == 0 { + return in + } + out := in[:1] + for _, s := range in[1:] { + if s != out[len(out)-1] { + out = append(out, s) + } + } + return out +} + +// inDir reports whether a source file lives under a directory, which is how a +// record is recognised as console-managed. +func inDir(file, dir string) bool { + if file == "" || dir == "" { + return false + } + abs, err := filepath.Abs(file) + if err != nil { + abs = file + } + base, err := filepath.Abs(dir) + if err != nil { + base = dir + } + rel, err := filepath.Rel(base, abs) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/localzone/store.go b/internal/localzone/store.go index 1b75a1a..f3811eb 100644 --- a/internal/localzone/store.go +++ b/internal/localzone/store.go @@ -41,9 +41,21 @@ type zone struct { ns map[uint16][]dns.RR // delegations are the names served by NS records (a cut below the apex). delegations map[string]bool - minTTL uint32 + // files maps an index key ("name|TYPE", or "*./name|TYPE" for a + // wildcard) to the file the record was parsed from. Only the admin + // API's zone browser reads it, so the query path pays nothing for it. + files map[string]string + // sources are the files this zone was compiled from, in file order. + sources []string + minTTL uint32 } +// wildKey is the index key a wildcard record is stored under. +func wildKey(name, rrtype string) string { return "*./" + name + "|" + rrtype } + +// source reports the file a record was parsed from ("" when unknown). +func (z *zone) source(key string) string { return z.files[key] } + // Options tunes zone loading. type Options struct { // Origins overrides the per-file origin derivation. @@ -80,11 +92,13 @@ func Load(paths []string, opt Options) (*Store, error) { exact: make(map[string][]dns.RR), wild: make(map[string]map[uint16][]dns.RR), delegations: make(map[string]bool), + files: make(map[string]string), ns: make(map[uint16][]dns.RR), minTTL: 0xFFFFFFFF, } s.zones[apex] = zz } + zz.noteSource(f) for _, rec := range z.Records { if add(zz, rec) { s.count++ @@ -137,13 +151,28 @@ func add(z *zone, rec zonefile.Record) bool { z.wild[key] = m } m[hdr.Rrtype] = append(m[hdr.Rrtype], rec.RR) + z.files[wildKey(key, dns.TypeToString[hdr.Rrtype])] = rec.File return true } k := name + "|" + dns.TypeToString[hdr.Rrtype] z.exact[k] = append(z.exact[k], rec.RR) + z.files[k] = rec.File return true } +// noteSource records a contributing file, keeping the order files were read. +func (z *zone) noteSource(file string) { + if file == "" { + return + } + for _, f := range z.sources { + if f == file { + return + } + } + z.sources = append(z.sources, file) +} + func inZone(name, origin string) bool { if origin == "." { return true diff --git a/web/src/App.tsx b/web/src/App.tsx index 623c18b..807e62b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,7 @@ import LocalZones from './pages/LocalZones.tsx' import NotFound from './pages/NotFound.tsx' import Settings from './pages/Settings.tsx' import Upstreams from './pages/Upstreams.tsx' +import Zones from './pages/Zones.tsx' const THEME_KEY = 'fancydns.theme' @@ -52,6 +53,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index bcb72dc..080f59f 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -7,6 +7,7 @@ import { FileCode2, Globe, LayoutDashboard, + Layers, ListChecks, Lock, LogOut, @@ -39,6 +40,7 @@ const NAV: NavItem[] = [ { to: '/blocklists', label: 'Blocklists', icon: ListChecks }, { to: '/domains', label: 'Domains', icon: Globe }, { to: '/local-zones', label: 'Local zones', icon: FileCode2 }, + { to: '/zones', label: 'Zones', icon: Layers }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/upstreams', label: 'Upstreams', icon: Network }, { to: '/logs', label: 'Logs', icon: ScrollText }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 56bac84..a0b2126 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -38,6 +38,10 @@ import type { UpstreamsResponse, ValidateResponse, Whoami, + ZoneDelegationInput, + ZoneRecordInput, + ZonesResponse, + ZoneWriteResponse, } from './types.ts' const TOKEN_KEY = 'fancydns.token' @@ -274,6 +278,53 @@ export function checkDomain(name: string): Promise { return apiFetch(apiUrl('/api/domains/check', { name })) } +export function getZones(): Promise { + return apiFetch('/api/zones') +} + +export function addZoneRecord(input: ZoneRecordInput): Promise { + return apiFetch('/api/zones/records', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export function putZoneRecord(input: ZoneRecordInput): Promise { + return apiFetch('/api/zones/records', { + method: 'PUT', + body: JSON.stringify(input), + }) +} + +export function deleteZoneRecord( + zone: string, + name: string, + type: string, + value?: string, +): Promise { + return apiFetch( + apiUrl('/api/zones/records', { zone, name, type, value }), + { method: 'DELETE' }, + ) +} + +export function addZoneDelegation(input: ZoneDelegationInput): Promise { + return apiFetch('/api/zones/delegations', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export function deleteZoneDelegation(zone: string, name: string): Promise { + return apiFetch(apiUrl('/api/zones/delegations', { zone, name }), { + method: 'DELETE', + }) +} + +export function deleteZone(zone: string): Promise { + return apiFetch(apiUrl('/api/zones', { zone }), { method: 'DELETE' }) +} + export function getClients(): Promise { return apiFetch('/api/clients') } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index a18d402..3ae8d33 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -620,3 +620,80 @@ export interface AddDomainInput { source?: string content?: string } + +// --- Local zones ----------------------------------------------------------- + +/** + * One authoritative record. `value` is presentation-format RDATA with every + * domain name absolute, so it can be pasted back into the form unchanged. + */ +export interface ZoneRecord { + name: string + type: string + ttl: number + value: string + /** True when the owner was written `*.name`. */ + wildcard: boolean + /** True for an NS record below the apex: the cut. */ + delegation: boolean + /** True when the console may rewrite this record. */ + managed: boolean + /** The master file the record was parsed from. */ + source: string +} + +/** A cut in a zone: a name below the apex handed to other servers. */ +export interface ZoneDelegation { + name: string + targets: string[] + managed: boolean +} + +export interface Zone { + origin: string + serial: number + soa: string + default_ttl: number + records: ZoneRecord[] + delegations: ZoneDelegation[] + record_count: number + /** True when every record in the zone is the console's to rewrite. */ + managed: boolean + files: string[] +} + +export interface ZonesResponse { + zones: Zone[] + files: string[] + /** The directory the API may rewrite, `''` when writes are disabled. */ + managed_dir: string + writable: boolean + /** The record types the write endpoints accept, from the server. */ + types: string[] + records: number + built_at: string + /** Why the store is stale, `''` when it is current. */ + error: string +} + +export interface ZoneRecordInput { + zone: string + name: string + type: string + /** 0 means the zone's default TTL. */ + ttl: number + value: string +} + +export interface ZoneDelegationInput { + zone: string + name: string + targets: string[] + ttl: number +} + +export interface ZoneWriteResponse { + ok: boolean + zone: string + serial: number +} diff --git a/web/src/pages/Zones.tsx b/web/src/pages/Zones.tsx new file mode 100644 index 0000000..818568c --- /dev/null +++ b/web/src/pages/Zones.tsx @@ -0,0 +1,834 @@ +/** + * Zones — the local zones this resolver answers for itself, and the + * delegations that hand a name to another server. + * + * The console only ever writes the managed zone directory, so a record from an + * operator's master file is shown and left alone: it carries no edit controls, + * and the API would refuse it anyway. Everything the page does is a + * request-then-refetch, because every write recompiles the zone store and the + * serial in the answer is the cheapest way to see that the file moved. + */ + +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react' + +import { + addZoneDelegation, + addZoneRecord, + deleteZone, + deleteZoneDelegation, + deleteZoneRecord, + getZones, + putZoneRecord, +} from '../lib/api' +import { useWhoami } from '../lib/session.ts' +import type { + Zone, + ZoneDelegation, + ZoneDelegationInput, + ZoneRecord, + ZoneRecordInput, +} from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useDebounced } from '../hooks/useDebounced' +import { formatNumber, formatRelative, toLines } from '../lib/format' +import { + Badge, + Button, + Card, + ConfirmDialog, + Dialog, + EmptyState, + IconButton, + Input, + Select, + Table, + Tabs, + Textarea, + useToast, +} from '../components/ui' +import type { ToastOptions } from '../components/ui' +import { PageError, PageHeader } from '../components/layout' + +type Row = Record + +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) +} + +/** One hint per type, so the value field says what belongs in it. */ +const VALUE_HINTS: Record = { + A: 'An IPv4 address, e.g. 10.0.0.5', + AAAA: 'An IPv6 address, e.g. 2001:db8::5', + CNAME: 'The target name. A name without a trailing dot is completed with the zone.', + MX: 'Preference and exchange, e.g. 10 mail.example.com. — a bare exchange name is completed with the zone', + NS: 'A nameserver name. A bare name is completed with the zone', + PTR: 'The name this address maps back to', + SRV: 'Priority, weight, port and target, e.g. 0 1 443 target.example.com.', + TXT: 'One or more quoted strings, e.g. "v=spf1 -all"', + CAA: 'Flag, tag and value, e.g. 0 issue "letsencrypt.org"', +} + +function valueHint(type: string): string { + return VALUE_HINTS[type] ?? 'The RDATA, exactly as a zone file spells it' +} + +function typeTone(type: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' { + switch (type) { + case 'A': + case 'AAAA': + return 'ok' + case 'CNAME': + return 'info' + case 'NS': + return 'warn' + case 'TXT': + case 'SPF': + return 'muted' + case 'SRV': + case 'MX': + return 'default' + default: + return 'default' + } +} + +function matches(record: ZoneRecord, q: string): boolean { + if (q === '') return true + return ( + record.name.toLowerCase().includes(q) || + record.type.toLowerCase().includes(q) || + record.value.toLowerCase().includes(q) + ) +} + +/** The record form's state, for both adding and editing. */ +type RecordForm = { + zone: string + name: string + type: string + ttl: string + value: string +} + +const NEW_ZONE = '__new__' + +export default function Zones() { + const { toast } = useToast() + const { canWrite } = useWhoami() + const { data, error, loading, refetch } = useApi(getZones, []) + + const [origin, setOrigin] = useState('') + const [tab, setTab] = useState('records') + const [search, setSearch] = useState('') + const q = useDebounced(search, 200) + + const [recordOpen, setRecordOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(null) + const [formError, setFormError] = useState(null) + + const [cutOpen, setCutOpen] = useState(false) + const [cutZone, setCutZone] = useState('') + const [newZone, setNewZone] = useState('') + const [cutName, setCutName] = useState('') + const [cutTargets, setCutTargets] = useState('') + + const [pendingRecord, setPendingRecord] = useState(null) + const [pendingCut, setPendingCut] = useState(null) + const [pendingZone, setPendingZone] = useState(null) + + const zones = useMemo(() => data?.zones ?? [], [data]) + // A zone is selected as soon as there is one, and the selection follows a + // zone that has gone away rather than pointing at nothing. + useEffect(() => { + if (zones.length === 0) { + setOrigin('') + return + } + if (!zones.some((zone) => zone.origin === origin)) setOrigin(zones[0].origin) + }, [zones, origin]) + + const zone = useMemo( + () => zones.find((z) => z.origin === origin) ?? null, + [zones, origin], + ) + const writable = data?.writable === true + const canEdit = canWrite && writable + + const saveRecord = useAsyncAction(async (input: ZoneRecordInput) => + editing ? putZoneRecord(input) : addZoneRecord(input), + ) + const dropRecord = useAsyncAction(async (target: { zone: string; record: ZoneRecord }) => + deleteZoneRecord(target.zone, target.record.name, target.record.type, target.record.value), + ) + const makeCut = useAsyncAction(async (input: ZoneDelegationInput) => addZoneDelegation(input)) + const dropCut = useAsyncAction(async (name: string) => + deleteZoneDelegation(zone?.origin ?? '', name), + ) + const dropZone = useAsyncAction(async (target: string) => deleteZone(target)) + + const recordLines = useMemo(() => toLines(cutTargets), [cutTargets]) + const targets = useMemo( + () => + cutTargets + .split(/[\n,]+/) + .map((t) => t.trim()) + .filter((t) => t !== ''), + [cutTargets], + ) + + const openNew = (): void => { + setEditing(null) + setForm({ zone: origin, name: '', type: data?.types[0] ?? 'A', ttl: '', value: '' }) + setFormError(null) + setRecordOpen(true) + } + + const openEdit = (record: ZoneRecord): void => { + setEditing(record) + setForm({ + zone: zone?.origin ?? '', + name: record.name, + type: record.type, + ttl: String(record.ttl), + value: record.value, + }) + setFormError(null) + setRecordOpen(true) + } + + const submitRecord = async (): Promise => { + if (form === null) return + setFormError(null) + const zoneName = form.zone === NEW_ZONE ? newZone.trim() : form.zone + if (zoneName === '') { + setFormError('name the zone this record belongs to') + return + } + if (form.name.trim() === '') { + setFormError('a record needs an owner name') + return + } + if (form.value.trim() === '') { + setFormError(`a ${form.type} record needs a value`) + return + } + const res = await saveRecord.run({ + zone: zoneName, + name: form.name.trim(), + type: form.type, + ttl: Number(form.ttl) || 0, + value: form.value.trim(), + }) + if (res === null) { + const msg = saveRecord.error ?? 'the server rejected that record' + setFormError(msg) + notify(toast, 'fail', msg) + return + } + notify(toast, 'ok', `${editing ? 'replaced' : 'added'} ${form.name.trim()} ${form.type}`) + setOrigin(zoneName) + setRecordOpen(false) + setForm(null) + refetch() + } + + const submitCut = async (): Promise => { + setFormError(null) + const zoneName = cutZone === NEW_ZONE ? newZone.trim() : cutZone + if (zoneName === '') { + setFormError('name the zone to delegate from') + return + } + if (cutName.trim() === '') { + setFormError('name the zone being delegated') + return + } + if (targets.length === 0) { + setFormError('a delegation needs at least one nameserver') + return + } + const res = await makeCut.run({ zone: zoneName, name: cutName.trim(), targets, ttl: 0 }) + if (res === null) { + const msg = makeCut.error ?? 'the server rejected that delegation' + setFormError(msg) + notify(toast, 'fail', msg) + return + } + notify(toast, 'ok', `delegated ${cutName.trim()}`) + setOrigin(zoneName) + setCutOpen(false) + setCutName('') + setCutTargets('') + refetch() + } + + const confirmRecord = async (): Promise => { + const target = pendingRecord + const zoneName = zone?.origin ?? '' + if (target === null) return + const res = await dropRecord.run({ zone: zoneName, record: target }) + if (res === null) { + notify(toast, 'fail', dropRecord.error ?? `could not delete ${target.name}`) + return + } + setPendingRecord(null) + notify(toast, 'ok', `deleted ${target.name} ${target.type}`) + refetch() + } + + const confirmCut = async (): Promise => { + const target = pendingCut + if (target === null) return + const res = await dropCut.run(target.name) + if (res === null) { + notify(toast, 'fail', dropCut.error ?? `could not remove the delegation for ${target.name}`) + return + } + setPendingCut(null) + notify(toast, 'ok', `${target.name} answers authoritatively again`) + refetch() + } + + const confirmZone = async (): Promise => { + const target = pendingZone + if (target === null) return + const res = await dropZone.run(target.origin) + if (res === null) { + notify(toast, 'fail', dropZone.error ?? `could not delete ${target.origin}`) + return + } + setPendingZone(null) + notify(toast, 'ok', `deleted ${target.origin}`) + refetch() + } + + if (error) return + + const records = (zone?.records ?? []).filter((record) => matches(record, q.trim().toLowerCase())) + const delegations = zone?.delegations ?? [] + const noWriteReason = !canWrite + ? 'This API key is read-only' + : !writable + ? 'No writable managed zone directory' + : undefined + + const recordColumns = [ + { key: 'name', header: 'Name', width: '32%' }, + { key: 'type', header: 'Type', width: '8%' }, + { key: 'value', header: 'Value', width: '34%' }, + { key: 'ttl', header: 'TTL', width: '8%', align: 'right' as const }, + { key: 'source', header: 'File', width: '14%' }, + { key: 'actions', header: '', width: '4%', align: 'right' as const }, + ] + const recordRows: Row[] = records.map((record, i) => ({ + __key: `${record.name}-${record.type}-${record.value}-${i}`, + name: ( + + {record.name} + {record.wildcard ? (wildcard) : null} + + ), + type: {record.type}, + value: ( + + {record.value} + + ), + ttl: {formatNumber(record.ttl)}, + source: ( + + {record.source || '—'} + + ), + actions: ( +
+ {record.managed ? ( + <> + openEdit(record)} + /> + setPendingRecord(record)} + /> + + ) : ( + + read-only + + )} +
+ ), + })) + + const cutRows: Row[] = delegations.map((cut, i) => ({ + __key: `${cut.name}-${i}`, + name: ( + + {cut.name} + + ), + targets: ( + + {cut.targets.join(', ')} + + ), + managed: cut.managed ? '—' : 'read-only', + actions: ( + setPendingCut(cut)} + /> + ), + })) + + return ( +
+ + + +
+ } + /> + + {data !== null && data.managed_dir === '' ? ( + + Set blocklist.managed_zones to a writable directory and + this page can create zones, add records and delegate names. The zones below are + compiled from blocklist.local_zones and are not + rewritten by the console. + + ) : null} + {data !== null && data.managed_dir !== '' && !data.writable ? ( + + {data.managed_dir} exists but the daemon cannot + write to it, so the console is read-only. The zone files themselves are fine. + + ) : null} + {data !== null && data.error !== '' ? ( + + {data.error}. The last good version is still being served. + + ) : null} + + {zones.length === 0 ? ( + + + + ) : ( + <> + +
+ setSearch(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ ) : ( +

+ A delegated name, and everything under it, is answered by the servers named + here — not by this resolver. +

+ )} + + +
+ {tab === 'records' ? ( + String(row.__key)} + loading={loading} + empty={ + + } + /> + ) : ( +
String(row.__key)} + empty={ + + } + /> + )} + + + + )} + + { + setRecordOpen(false) + setForm(null) + }} + title={editing ? `Edit ${editing.name} ${editing.type}` : 'Add a record'} + description={ + editing + ? 'Saving replaces every record of this name and type in this zone.' + : 'A record that does not exist yet in this zone is added; the same name and type is added to the RRset.' + } + footer={ +
+ + +
+ } + > + {form !== null ? ( +
+ {formError !== null ? {formError} : null} + setForm({ ...form, zone: value })} + onNewZone={setNewZone} + fixed={editing !== null} + /> +
+ setForm({ ...form, name: e.target.value })} + autoComplete="off" + spellCheck={false} + disabled={editing !== null} + /> + setForm({ ...form, value: e.target.value })} + autoComplete="off" + spellCheck={false} + /> + setForm({ ...form, ttl: e.target.value.replace(/[^0-9]/g, '') })} + inputMode="numeric" + /> +
+ ) : null} +
+ + setCutOpen(false)} + title="Delegate a zone" + description="The names below the delegated name stop being answered here, except for the delegation itself." + footer={ +
+ + +
+ } + > +
+ {formError !== null ? {formError} : null} + + setCutName(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +