From 504512f24773fff534b0fb43f6ef137ebcf553ad Mon Sep 17 00:00:00 2001 From: developer Date: Mon, 28 Sep 2026 07:42:32 +0000 Subject: [PATCH] Add a Zones console: create and manage local zones, records and delegations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at. --- AGENTS.md | 13 + README.md | 45 ++ STATUS.md | 50 ++ deploy/config.example.yaml | 6 + internal/api/api.go | 24 + internal/api/harness_test.go | 28 + internal/api/routes_test.go | 10 + internal/api/zones.go | 261 ++++++++ internal/api/zones_test.go | 157 +++++ internal/app/app.go | 59 +- internal/app/contract_test.go | 60 ++ internal/app/hooks.go | 67 +- internal/app/integration_test.go | 241 +++++++ internal/app/zones.go | 248 ++++++++ internal/config/config.go | 9 + internal/engine/engine.go | 8 +- internal/localzone/managed.go | 833 ++++++++++++++++++++++++ internal/localzone/managed_test.go | 704 +++++++++++++++++++++ internal/localzone/records.go | 300 +++++++++ internal/localzone/store.go | 31 +- web/src/App.tsx | 2 + web/src/components/layout/AppShell.tsx | 2 + web/src/lib/api.ts | 51 ++ web/src/lib/types.ts | 77 +++ web/src/pages/Zones.tsx | 834 +++++++++++++++++++++++++ 25 files changed, 4064 insertions(+), 56 deletions(-) create mode 100644 internal/api/zones.go create mode 100644 internal/api/zones_test.go create mode 100644 internal/app/zones.go create mode 100644 internal/localzone/managed.go create mode 100644 internal/localzone/managed_test.go create mode 100644 internal/localzone/records.go create mode 100644 web/src/pages/Zones.tsx 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} + /> +