diff --git a/AGENTS.md b/AGENTS.md index 9767084..b8174d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Cache is a single shard-local lookup; only a miss touches the network. |---|---|---| | Scope | Full production | clustering, metrics, ACLs, access log, 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 | +| Editable data | Cluster objects (`DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride`, `DomainRule`) written only by `fancy-dns-console`; **no ConfigMap-backed editors, no managed zone directory** | the resolver only reads them (a watch), so it runs anywhere with a kubeconfig, in or out of the cluster. YAML stays for deployment config (listeners, upstreams, cache). The old ConfigMap/RPZ/managed-directory writers are gone | | 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 | | Observability | Prometheus metrics + a JSON access log on stdout; **no in-app dashboard, log store or live tail** | the resolver is DNS only. Charts belong in Grafana, search in the log collector, and per-pod state in a multi-node deployment only disagrees with itself. The access log is blocked/errors by default, goes through a bounded buffer that drops (and counts) rather than ever blocking a query, and nothing may be added to the query path to feed a UI | @@ -140,11 +140,12 @@ 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 is mmapped and lives 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 proves its own rendering.** Every record is validated by the + project's own master-file parser, the zone is rendered, and the rendering is + re-parsed and compared with the records it meant to write *before* it is + served (`localzone.RenderZone`, used by both the runtime and the console's + `kubesource.CheckZone`). A renderer 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, diff --git a/README.md b/README.md index 670feb7..b921ffb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # fancy-dns -A recursive DNS server with blocklists, per-client ACLs, a query log, an -embedded admin console and optional clustering over Valkey/Redis. +A recursive DNS server with blocklists, per-client ACLs, Prometheus metrics, a +JSON access log, a separate admin console and optional clustering over +Valkey/Redis. * **What it is** — a single Go binary that answers DNS over UDP, TCP, DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484), resolves everything @@ -24,7 +25,8 @@ Each line points at the package that implements it. | Blocklists from hosts, Adblock Plus, plain and RPZ sources, compiled into one immutable set that is swapped atomically | `internal/blocklist` | | Authoritative local data and RPZ policy zones from RFC 1035 master files | `internal/zonefile`, `internal/localzone` | | Per-client ACL groups matched on CIDR, PTR suffix or reverse-name tag, with per-group domain policy, rate limit, concurrency cap, cache and blocklist toggles | `internal/acl` | -| Query log in a bbolt ring plus an in-memory live ring, sampling, per-domain counters and an SSE stream | `internal/qlog` | +| JSON access log on stdout (blocked and failed queries by default) through a buffer that drops rather than blocks | `internal/accesslog` | +| Cluster objects (`DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride`, `DomainRule`) read with a watch, and written by the console | `internal/kubesource`, `internal/kubeedit`, `internal/console` | | Prometheus metrics (`fancydns_*`) | `internal/metrics` | | Admin REST API with API-key / basic / no auth, read-only keys, CORS, per-IP rate limiting and login lockout | `internal/api` | | Admin console embedded in the binary with `go:embed` | `internal/web`, `web/` | @@ -85,9 +87,8 @@ blocklist: enabled: true # lists are OFF unless you say enabled: true refresh: 0s # 0 disables the scheduled refresh -qlog: - enabled: true - path: data/queries.db +access_log: + enabled: true # one JSON line on stdout per blocked or failed query api: enabled: true @@ -249,7 +250,6 @@ 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 | @@ -284,25 +284,25 @@ file. | `acl.groups[].skip_cache` | bool | `false` | the group only gets fresh lookups | | `acl.groups[].rate_limit` | int | inherit | queries/second for this group | | `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client | -| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `qlog.sample_rate` | +| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `access_log` selection | | `acl.groups[].upstreams` | list of addrs | empty | restrict this group to named upstreams; a list that matches nothing falls back to the whole pool | -### qlog +### access_log + +One JSON line on stdout per query whose outcome is selected. The resolver keeps +no query log of its own: a log collector indexes these lines, and the Prometheus +metrics count everything. Lines go through a bounded buffer and are dropped, and +counted in `fancydns_access_log_dropped_total`, if the stream falls behind, so +logging can never stall a query. | Key | Type | Default | What it does | |---|---|---|---| -| `qlog.enabled` | bool | `true` | when off the bbolt file is not opened (the live ring and counters stay) | -| `qlog.path` | path | `data/queries.db` | bbolt ring file | -| `qlog.max_entries` | int | `200000` | ring bound; the oldest entries are dropped | -| `qlog.flush_interval` | duration | `1s` | batches writes onto a background goroutine | -| `qlog.sample_rate` | 0–1 | `1.0` | sampling for ordinary queries; blocked/error/cached logging is governed by the flags below | -| `qlog.log_blocked` | bool | `true` | always log blocks | -| `qlog.log_cached` | bool | `true` | log cache hits (set `false` on a busy instance) | -| `qlog.log_errors` | bool | `true` | always log SERVFAIL and friends | -| `qlog.log_client_names` | bool | `false` | **unused** | -| `qlog.live_buffer` | int | `2000` | in-memory ring that feeds the UI and the SSE stream | -| `qlog.top_domains` | int | `5000` | number of per-domain counters kept | -| `qlog.top_ttl` | duration | `5m` | how long those counters are aggregated | +| `access_log.enabled` | bool | `true` | | +| `access_log.log_blocked` | bool | `true` | blocklist hits and redirects | +| `access_log.log_errors` | bool | `true` | SERVFAIL, drops and ACL refusals | +| `access_log.log_cached` | bool | `false` | also cache hits | +| `access_log.sample_rate` | 0–1 | `0` | fraction of ordinary answers to log as well; for a debugging session | +| `access_log.buffer` | int | `4096` | lines that may wait for the stream before dropping | ### api @@ -432,51 +432,27 @@ 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 -them automatically. The API validates every file and its record owners before -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 +**Zones in Kubernetes are `DNSZone` objects.** With `-kube` the resolver watches +`DNSZone` objects and serves them next to the master files in `local_zones`; the +console (`fancy-dns-console`) is what writes them. The resolver itself writes no +zone data. A `DNSZone` is rendered into a master file and re-parsed before it is +served, and one that does not render is rejected without touching the zones that +already work. Rules the renderer holds to, all 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 record 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. +* What is rendered is re-parsed and compared with what it meant to write. A + round-trip that loses a record is a renderer bug, caught there. * 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. + the apex. The SOA's primary follows the apex NS set, and its serial is the + object's generation, so every replica serves identical bytes. +* A record at or below a delegation is refused: those names are answered as + delegated and the record would look alive and never answer. -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. +To move an existing deployment onto these objects, see +[deploy/MIGRATION.md](deploy/MIGRATION.md). A standalone install keeps its zones +as master files on disk and reloads with SIGHUP. ## Blocklists @@ -536,7 +512,6 @@ is on. | POST | `/api/auth/login` | — | exchange an API key or user/password for a bearer token (public) | | POST | `/api/auth/logout` | yes | revoke the caller's bearer token | | GET | `/api/whoami` | — | caller's name, mode, `read_only`, `can_write` | -| GET | `/api/dashboard?range=…` | — | aggregated counters and the rolling series | | GET | `/api/status` | — | version, uptime, config generation/last error, per-component status, listeners | | GET | `/api/blocklists` | — | compiled state, per-source metadata, errors | | POST | `/api/blocklists/refresh` | yes | recompile now; body `{"names":[…]}` or empty for everything | @@ -546,22 +521,19 @@ is on. | POST | `/api/domains` | yes | add an inline rule | | POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request | | 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`) | +| GET | `/api/zones` | — | every local zone with its records, delegations, source files, whether each is an editable cluster object, the record types the console writes, 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 | +| DELETE | `/api/zones?zone=` | yes | delete a `DNSZone`, so its names go back to whatever answered for them before (console only) | | 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 | | PUT | `/api/upstreams` | yes | replace policy/servers/bootstraps/attempts/threshold (cooldown is preserved) | -| GET | `/api/logs?q=&action=&client=&rcode=&blocked=&since=&limit=&offset=` | — | page the query log | -| GET | `/api/logs/stream` | — | Server-Sent Events: `ready`, `entry`, `stats` and `: ping` keep-alives | -| POST | `/api/logs/clear` | yes | empty the query log | | GET | `/api/cache` | — | cache statistics | -| GET | `/api/cache/top?limit=` | — | hottest cached names | +| GET | `/api/links` | — | the configured `api.links` (Grafana, log view, metrics) | | POST | `/api/cache/flush` | yes | drop the cache (also done after a rule change) | | GET | `/api/settings` | — | file YAML, effective YAML, defaults YAML, writability | | PUT | `/api/settings` | yes | validate, write (atomic, mode 0600), bump `version`, swap, broadcast; needs `api.allow_config_write` | diff --git a/deploy/MIGRATION.md b/deploy/MIGRATION.md new file mode 100644 index 0000000..d6dc1a5 --- /dev/null +++ b/deploy/MIGRATION.md @@ -0,0 +1,89 @@ +# Moving a ConfigMap deployment to cluster objects + +Until now the console edited zones and address redirects by writing two +ConfigMaps (`fancy-dns-zones`, `fancy-dns-rpz`) through the resolver's service +account, and the domains page rewrote the config file. Those mechanisms are gone. +Everything the console edits is now an object: + +| Was | Is | +|---|---| +| `zone--.zone` keys in the `fancy-dns-zones` ConfigMap | `DNSZone` | +| `address-overrides.rpz` in the `fancy-dns-rpz` ConfigMap | `AddressOverride` (one per name) | +| `blocklist.allow_entries` / `block_entries` | `DomainRule` (`inline-allow`, `inline-block`) | +| `blocklist.lists` with a URL | `BlockList` | +| `acl.groups` | `ClientGroup` | +| listeners, upstreams, cache, `default_policy` | still the resolver's YAML | + +The old resolver does not read the objects and the new one does not read the +ConfigMaps, so the order below matters: **create the objects while the old +resolver is still serving, switch, then delete the ConfigMaps.** Nothing here +deletes anything until the last step. + +## 1. Install the definitions and permissions + +```sh +kubectl apply -f deploy/k8s/crds/crds.yaml +kubectl apply -k deploy/k8s/crds # lets the resolver's service account read the objects +kubectl apply -f deploy/k8s/console/console.yaml # the console, which writes them (create its two Secrets first) +``` + +## 2. Convert what you have + +The command reads the two ConfigMaps from the cluster and, if you give it the +resolver's `config.yaml`, its URL lists, inline entries and ACL groups. By +default it only **prints** manifests. + +```sh +kubectl -n dns get cm fancy-dns-config -o jsonpath='{.data.config\.yaml}' > /tmp/config.yaml + +FANCYDNS_API_KEY=unused \ +fancy-dns-console migrate -kube-namespace dns -config /tmp/config.yaml > migrated.yaml +``` + +(`FANCYDNS_API_KEY=unused`: the config expands `${VAR}` references and refuses an +unset one; the value is not used.) Read `migrated.yaml`, and read what the +command printed on stderr: + +* `converted:` lines say what became an object and what to remove from the config. +* `NOT converted:` lines are things it could not represent faithfully (a zone + with a record type the console does not write, a list that is a file on the + pod, a name that is not a legal object name). Those stay where they are. + +Commit `migrated.yaml` if you manage the cluster with GitOps, or apply it: + +```sh +fancy-dns-console migrate -kube-namespace dns -config /tmp/config.yaml -apply +``` + +It is safe to run again: objects are created or updated, never duplicated. + +## 3. Switch the resolver + +* Start it with `-kube` (the StatefulSet in `deploy/k8s/10-deployment.yaml` does). +* In `config.yaml`, remove what moved: the `edge-router-x-address-overrides` list + and any other list you converted, `acl.groups`, the inline entries, + `blocklist.managed_zones` and the `fancy-dns-zones` glob in `local_zones`. +* Roll it out. + +## 4. Check before you delete anything + +```sh +kubectl -n dns get dnszones,addressoverrides,clientgroups,blocklists,domainrules +curl -s https:///api/status # components.degraded must not mention kubernetes +dig @ +``` + +A rejected object is not a silent failure: it is logged by the resolver, the +node reports "kubernetes: objects rejected" as degraded, and an object that was +valid before keeps its last good version. + +## 5. Delete the old ConfigMaps + +Only now: + +```sh +kubectl -n dns delete cm fancy-dns-zones fancy-dns-rpz +``` + +If your GitOps tool prunes, remove them from the repository **after** step 4, not +before: they hold the only copy of the data until the objects exist. diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index a5a3044..58f1026 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -113,12 +113,6 @@ 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/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml index b240e26..0edcc8a 100644 --- a/deploy/k8s/00-config.yaml +++ b/deploy/k8s/00-config.yaml @@ -86,7 +86,6 @@ data: blocklist: enabled: true default_action: nxdomain - managed_zones: /var/lib/fancy-dns/managed-zones local_zones: [/etc/fancy-dns/zones/*.zone] lists: - name: edge-router-x-address-overrides diff --git a/internal/api/api.go b/internal/api/api.go index e40519d..8bfa9cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -72,11 +72,7 @@ type Hooks struct { // AddDomain adds an inline rule and persists it. AddDomain func(ctx context.Context, in DomainInput) (any, error) // DeleteDomain removes a rule and persists the change. - DeleteDomain func(ctx context.Context, name, action, list string) error - // LocalZones lists, saves and deletes UI-managed DNS master files. - LocalZones func(ctx context.Context) ([]ZoneFile, error) - SaveLocalZone func(ctx context.Context, name, content string) error - DeleteLocalZone func(ctx context.Context, name string) error + DeleteDomain func(ctx context.Context, name, action, list string) error AddressOverrides func(ctx context.Context) (any, error) SaveAddressOverride func(ctx context.Context, domain, address string) error DeleteAddressOverride func(ctx context.Context, domain string) error @@ -135,12 +131,6 @@ type DomainInput struct { Content string `json:"content,omitempty"` } -// ZoneFile is a UI-managed authoritative master file. -type ZoneFile struct { - Name string `json:"name"` - Content string `json:"content"` -} - // Server is the admin HTTP server. type Server struct { hooks Hooks @@ -247,9 +237,6 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { mux.Handle("GET "+base+"address-overrides", s.authed(s.handleAddressOverrides, false)) mux.Handle("PUT "+base+"address-overrides", s.authed(s.handleAddressOverridePut, true)) mux.Handle("DELETE "+base+"address-overrides/{domain}", s.authed(s.handleAddressOverrideDelete, true)) - 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)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3887edd..529235b 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -253,52 +253,6 @@ func (s *Server) handleAddressOverrideDelete(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) { - if s.hooks.LocalZones == nil { - writeError(w, http.StatusNotImplemented, "local zone editor is unavailable") - return - } - zones, err := s.hooks.LocalZones(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{"zones": zones}) -} - -func (s *Server) handleLocalZonePut(w http.ResponseWriter, r *http.Request) { - if s.hooks.SaveLocalZone == nil { - writeError(w, http.StatusNotImplemented, "local zone editor is unavailable") - return - } - var in struct { - Content string `json:"content"` - } - if err := decodeJSON(r, &in); err != nil { - writeError(w, http.StatusBadRequest, "malformed request body") - return - } - name := r.PathValue("name") - if err := s.hooks.SaveLocalZone(r.Context(), name, in.Content); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) -} - -func (s *Server) handleLocalZoneDelete(w http.ResponseWriter, r *http.Request) { - if s.hooks.DeleteLocalZone == nil { - writeError(w, http.StatusNotImplemented, "local zone editor is unavailable") - return - } - name := r.PathValue("name") - if err := s.hooks.DeleteLocalZone(r.Context(), name); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) -} - // handleDomainAdd adds an inline rule. func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) { if s.hooks.AddDomain == nil { diff --git a/internal/api/zones.go b/internal/api/zones.go index 4ffbd44..ce9adc1 100644 --- a/internal/api/zones.go +++ b/internal/api/zones.go @@ -1,7 +1,6 @@ package api import ( - "errors" "net/http" "strings" "time" @@ -109,22 +108,12 @@ func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) { return } if err := s.hooks.DeleteZone(r.Context(), zone); err != nil { - writeError(w, zoneWriteStatus(err), err.Error()) + writeError(w, http.StatusNotFound, 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 { @@ -186,7 +175,7 @@ func (s *Server) handleZoneRecordDelete(w http.ResponseWriter, r *http.Request) return } if err := s.hooks.DeleteZoneRecord(r.Context(), zone, name, rrtype, r.URL.Query().Get("value")); err != nil { - writeError(w, zoneWriteStatus(err), err.Error()) + writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name, "type": rrtype}) @@ -236,7 +225,7 @@ func (s *Server) handleZoneDelegationDelete(w http.ResponseWriter, r *http.Reque return } if err := s.hooks.DeleteZoneDelegation(r.Context(), zone, name); err != nil { - writeError(w, zoneWriteStatus(err), err.Error()) + writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name}) diff --git a/internal/app/app.go b/internal/app/app.go index c46656a..4ae8f32 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,7 +29,6 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/engine" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" - "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubezones" "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" "git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics" "git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver" @@ -63,29 +62,22 @@ type App struct { cfg *config.Store ver string - blocks *blocklist.Manager - overrideMu sync.Mutex - cache *cache.Manager - cstore *cache.Store - logger *accesslog.Logger - aclH *acl.Holder - local *localzone.Holder - // zones is the Kubernetes-sourced zone client. - zones *kubezones.Client + blocks *blocklist.Manager + cache *cache.Manager + cstore *cache.Store + logger *accesslog.Logger + aclH *acl.Holder + local *localzone.Holder // kube watches the cluster's fancy-dns objects when enabled; kubeSnap is // what the config overlay merges in. kube *kubesource.Source kubeSnap atomic.Pointer[kubesource.Snapshot] - // 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 + pool *resolver.Pool + reverse *engine.ReverseCache + engine *engine.Engine + dns *server.Server + api *api.Server + cl *cluster.Cluster apiServers []*http.Server apiLn []net.Listener @@ -116,11 +108,7 @@ func New(opt Options) (*App, error) { return nil, err } store := config.NewStore(cfg, opt.ConfigPath) - zones, err := kubezones.InCluster() - if err != nil { - return nil, err - } - return &App{opt: opt, log: log, cfg: store, ver: opt.Version, zones: zones}, nil + return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil } // newLogger builds the structured logger from config plus CLI overrides. @@ -322,22 +310,9 @@ func (a *App) startBlocklist() { Config: a.cfg, Logger: a.log, }) - if a.zones != nil { - ctx := context.Background() - content, exists, err := a.zones.Data(ctx, "address-overrides.rpz") - if err == nil && !exists { - if seed, readErr := os.ReadFile("/etc/fancy-dns/rpz/address-overrides.rpz"); readErr == nil { - content = string(seed) - if saveErr := a.zones.SaveData(ctx, "address-overrides.rpz", content); saveErr != nil { - a.log.Warn("seed address overrides failed", "error", saveErr) - } - } - } - if err == nil { - a.blocks.SetManagedRPZ(content) - } else { - a.log.Warn("load address overrides failed", "error", err) - } + // The cluster's address overrides, if the Kubernetes source has any yet. + if snap := a.kubeSnap.Load(); snap != nil { + a.blocks.SetManagedRPZ(kubesource.RenderRPZ(snap.Overrides)) } // Compile once before serving so the first queries are not unprotected. if _, err := a.blocks.RefreshAll(context.Background()); err != nil { @@ -349,14 +324,9 @@ func (a *App) startBlocklist() { // startLocalZones compiles the local master files. func (a *App) startLocalZones(cfg *config.Config) { a.local = localzone.NewHolder(nil) - 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() { @@ -367,13 +337,8 @@ func (a *App) reloadLocalZones() { 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()) - } + // DNSZone objects are rendered into a directory of their own, which is a + // zone source like any other. if dir := a.kubeZoneDir(); dir != "" { paths = append(paths, dir) } @@ -600,8 +565,6 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) { // startBackground runs the periodic jobs: cache snapshots, upstream health // probes and the metrics gauge. func (a *App) startBackground(ctx context.Context, cfg *config.Config) { - go a.watchLocalZones(ctx) - go a.watchAddressOverrides(ctx) // Cache snapshots. if a.cstore != nil { interval := cfg.Cache.Persist.Interval.D() diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index deed594..64e524b 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -2,16 +2,19 @@ package app import ( "bufio" + "context" "encoding/json" "io" "net/http" - "path/filepath" "sort" "strconv" "strings" "testing" + "time" - "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" ) // The contract tests in this file exist because the console's TypeScript types @@ -469,21 +472,25 @@ func TestRemainingEndpointKeySets(t *testing.T) { // 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) - } + client := kubeClient() + h := newHarness(t, nil, func(o *Options) { + o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: client, SyncTimeout: 5 * time.Second} + }) + // A zone with a record and a delegation, from the cluster. + if _, err := client.Resource(kubesource.ZoneGVR).Namespace("dns").Create(context.Background(), kubeObj("DNSZone", "console", map[string]any{ + "zone": "console.test", + "records": []any{ + map[string]any{"name": "@", "type": "NS", "value": "ns1.console.test."}, + map[string]any{"name": "www", "type": "A", "ttl": int64(60), "value": "10.9.9.9"}, + map[string]any{"name": "sub", "type": "NS", "value": "ns1.sub.console.test."}, + }, + }), metav1.CreateOptions{}); err != nil { + t.Fatal(err) } - // 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."]}`) + eventually(t, "the zone to be listed", func() bool { + _, body := h.get("/api/zones") + return strings.Contains(string(body), "console.test.") + }) t.Run("zones", func(t *testing.T) { code, body := h.get("/api/zones") diff --git a/internal/app/hooks.go b/internal/app/hooks.go index a0e5660..5a35292 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -17,49 +17,36 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/engine" "git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver" - "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) // hooks builds the api.Hooks that connect the admin API to the live // 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, - Status: a.status, - Health: a.health, - BlocklistState: a.blocklistState, - BlocklistStats: a.blocklistStats, - RefreshBlocklists: a.refreshBlocklists, - ToggleBlocklist: a.toggleBlocklist, - DomainCheck: a.domainCheck, - Resolve: a.resolveTest, - ResolveUpstream: a.resolveTestUpstream, - AddressOverrides: a.listAddressOverrides, - SaveAddressOverride: a.saveAddressOverride, - DeleteAddressOverride: a.deleteAddressOverride, - 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, - CacheStats: a.cacheStats, - FlushCache: a.flushCache, - Flush: a.flush, + Store: a.cfg, + Logger: a.log, + Version: a.ver, + StartedAt: a.startedAt, + Status: a.status, + Health: a.health, + BlocklistState: a.blocklistState, + BlocklistStats: a.blocklistStats, + RefreshBlocklists: a.refreshBlocklists, + ToggleBlocklist: a.toggleBlocklist, + DomainCheck: a.domainCheck, + Resolve: a.resolveTest, + ResolveUpstream: a.resolveTestUpstream, + ListDomains: a.listDomains, + AddDomain: a.addDomain, + DeleteDomain: a.deleteDomain, + ListZones: a.listZones, + GetACL: a.getACL, + PutACL: a.putACL, + GetUpstream: a.getUpstreams, + PutUpstream: a.putUpstream, + CacheStats: a.cacheStats, + FlushCache: a.flushCache, + Flush: a.flush, } } @@ -99,156 +86,6 @@ func (a *App) resolveTestUpstream(ctx context.Context, name, qtype, upstream str return out, nil } -type addressOverride struct { - Domain string `json:"domain"` - Address string `json:"address"` -} - -func (a *App) listAddressOverrides(ctx context.Context) (any, error) { - if a.zones == nil { - return map[string]any{"items": []addressOverride{}, "writable": false}, nil - } - raw, ok, err := a.zones.Data(ctx, "address-overrides.rpz") - if err != nil { - return nil, err - } - items := parseAddressOverrides(raw) - return map[string]any{"items": items, "writable": true, "initialized": ok}, nil -} -func parseAddressOverrides(raw string) []addressOverride { - out := []addressOverride{} - for _, line := range strings.Split(raw, "\n") { - f := strings.Fields(line) - if len(f) >= 3 && !strings.HasPrefix(f[0], "$") && (strings.EqualFold(f[1], "A") || strings.EqualFold(f[1], "AAAA")) { - out = append(out, addressOverride{Domain: strings.TrimSuffix(f[0], "."), Address: f[2]}) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) - return out -} -func (a *App) saveAddressOverride(ctx context.Context, domain, address string) error { - return a.mutateAddressOverride(ctx, domain, address, false) -} -func (a *App) deleteAddressOverride(ctx context.Context, domain string) error { - return a.mutateAddressOverride(ctx, domain, "", true) -} -func (a *App) mutateAddressOverride(ctx context.Context, domain, address string, remove bool) error { - a.overrideMu.Lock() - defer a.overrideMu.Unlock() - if a.zones == nil { - return errors.New("address overrides are writable only in Kubernetes") - } - domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") - if _, ok := dns.IsDomainName(dns.Fqdn(domain)); !ok || domain == "" { - return errors.New("enter a valid domain name") - } - ip, err := netip.ParseAddr(strings.TrimSpace(address)) - if !remove && err != nil { - return errors.New("enter a valid IPv4 or IPv6 address") - } - raw, _, err := a.zones.Data(ctx, "address-overrides.rpz") - if err != nil { - return err - } - items := parseAddressOverrides(raw) - next := make([]addressOverride, 0, len(items)+1) - for _, it := range items { - if it.Domain != domain { - next = append(next, it) - } - } - if !remove { - next = append(next, addressOverride{Domain: domain, Address: ip.String()}) - } - sort.Slice(next, func(i, j int) bool { return next[i].Domain < next[j].Domain }) - var b strings.Builder - b.WriteString("$TTL 60\n") - for _, it := range next { - typ := "A" - if strings.Contains(it.Address, ":") { - typ = "AAAA" - } - fmt.Fprintf(&b, "%s %s %s\n", it.Domain, typ, it.Address) - } - if err := a.zones.SaveData(ctx, "address-overrides.rpz", b.String()); err != nil { - return err - } - if a.blocks != nil { - a.blocks.SetManagedRPZ(b.String()) - if _, err := a.blocks.RefreshAll(ctx); err != nil { - return err - } - } - if a.cache != nil { - a.cache.Flush() - } - return nil -} - -func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) { - if a.zones == nil { - return nil, fmt.Errorf("local zone editor is available only in Kubernetes") - } - zones, err := a.zones.List(ctx) - if err != nil { - return nil, err - } - out := make([]api.ZoneFile, 0, len(zones)) - for _, z := range zones { - out = append(out, api.ZoneFile{Name: z.Name, Content: z.Content}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil -} - -func (a *App) saveLocalZone(ctx context.Context, name, content string) error { - if a.zones == nil { - return fmt.Errorf("local zone editor is available only in Kubernetes") - } - apex := strings.ToLower(dns.Fqdn(strings.TrimSuffix(strings.TrimSpace(name), "."))) - _, valid := dns.IsDomainName(apex) - if !valid || apex == "." || len(apex) > 243 { - return fmt.Errorf("invalid zone name %q", name) - } - if strings.TrimSpace(content) == "" { - return fmt.Errorf("zone file cannot be empty") - } - parsed, err := zonefile.Parse(strings.NewReader(content), zonefile.Options{Origin: apex, File: name + ".zone"}) - if err != nil { - return fmt.Errorf("invalid zone file: %w", err) - } - if len(parsed.Records) == 0 { - return fmt.Errorf("zone file must contain at least one record") - } - for _, record := range parsed.Records { - owner := strings.ToLower(dns.Fqdn(record.Name)) - if owner != strings.ToLower(apex) && !strings.HasSuffix(owner, "."+strings.ToLower(apex)) { - return fmt.Errorf("record %q is outside zone %s", owner, apex) - } - } - if err := a.zones.Save(ctx, strings.TrimSuffix(apex, "."), content); err != nil { - return err - } - if a.cache != nil { - a.cache.Flush() - } - return nil -} - -func (a *App) deleteLocalZone(ctx context.Context, name string) error { - if a.zones == nil { - return fmt.Errorf("local zone editor is available only in Kubernetes") - } - name = strings.TrimSuffix(strings.TrimSpace(name), ".") - if err := a.zones.Delete(ctx, name); err != nil { - return err - } - if a.cache != nil { - a.cache.Flush() - } - return nil -} - // health is the unauthenticated probe payload. func (a *App) health() any { status := "ok" diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 9d2c92a..19603c9 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" - "path/filepath" "strings" "testing" "time" @@ -15,170 +13,6 @@ 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. @@ -202,10 +36,11 @@ type zonesPayload struct { 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) { +// TestZonesAreReadOnlyOnTheResolver: the resolver lists the zones it serves and +// writes none of them. Editing a zone is the console's job and happens on the +// DNSZone objects (see console_e2e_test.go); every write route on the +// resolver answers "not available" and changes nothing. +func TestZonesAreReadOnlyOnTheResolver(t *testing.T) { h := newHarness(t, nil) code, body := h.get("/api/zones") if code != http.StatusOK { @@ -215,43 +50,29 @@ func TestZoneWritesNeedAManagedDirectory(t *testing.T) { 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.Writable || zones.ManagedDir != "" { + t.Errorf("writable=%v managed_dir=%q on a resolver with no Kubernetes source", zones.Writable, zones.ManagedDir) } - if zones.ManagedDir != "" { - t.Errorf("managed_dir = %q, want empty", zones.ManagedDir) + if len(zones.Zones) != 1 || zones.Zones[0].Origin != "local." || zones.Zones[0].Managed { + t.Errorf("zones = %+v, want only the operator's master file, not managed", zones.Zones) } - 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 - }{ + for _, w := range []struct{ method, path, 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.MethodDelete, "/api/zones/records?zone=local.&name=ns.local.&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 { + {http.MethodDelete, "/api/zones/delegations?zone=local.&name=sub.local.", ""}, + {http.MethodDelete, "/api/zones?zone=local.", ""}, + {http.MethodGet, "/api/local-zones", ""}, + } { 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) + if code != http.StatusNotImplemented && code != http.StatusNotFound && code != http.StatusMethodNotAllowed { + t.Errorf("%s %s = %d, want it refused: %s", w.method, w.path, code, out) } } + if m := h.query("ns.local", dns.TypeA); len(m.Answer) != 1 { + t.Errorf("the operator's zone stopped answering after the refused writes: %v", m) + } } // TestResolveUpstream covers the happy path: a name nobody blocks is forwarded diff --git a/internal/app/localzones_watch.go b/internal/app/localzones_watch.go deleted file mode 100644 index 951d278..0000000 --- a/internal/app/localzones_watch.go +++ /dev/null @@ -1,76 +0,0 @@ -package app - -import ( - "context" - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "sort" - "time" -) - -// watchLocalZones reloads projected ConfigMap files on every replica. A -// Kubernetes ConfigMap volume swaps its ..data symlink atomically when the UI -// edits a zone; polling the contents keeps both pods in sync without routing -// DNS changes through a single pod. -func (a *App) watchLocalZones(ctx context.Context) { - if a.zones == nil { - return - } - last, _ := a.localZoneFingerprint() - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - next, err := a.localZoneFingerprint() - if err != nil { - a.log.Warn("local zone change check failed", "error", err) - continue - } - if next == last { - continue - } - a.reloadLocalZones() - if a.cache != nil { - a.cache.Flush() - } - last = next - } - } -} - -func (a *App) localZoneFingerprint() (string, error) { - paths := make([]string, 0) - for _, configured := range a.cfg.Get().Block.LocalZones { - path := a.cfg.Get().AbsPath(configured) - if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 { - paths = append(paths, matches...) - continue - } - if info, err := os.Stat(path); err == nil && info.IsDir() { - matches, err := filepath.Glob(filepath.Join(path, "*.zone")) - if err != nil { - return "", err - } - paths = append(paths, matches...) - continue - } - } - sort.Strings(paths) - h := sha256.New() - for _, path := range paths { - b, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read %s: %w", path, err) - } - _, _ = h.Write([]byte(path)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write(b) - _, _ = h.Write([]byte{0}) - } - return fmt.Sprintf("%x", h.Sum(nil)), nil -} diff --git a/internal/app/overrides_watch.go b/internal/app/overrides_watch.go deleted file mode 100644 index 8ce58a6..0000000 --- a/internal/app/overrides_watch.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "context" - "time" -) - -func (a *App) watchAddressOverrides(ctx context.Context) { - if a.zones == nil || a.blocks == nil { - return - } - t := time.NewTicker(2 * time.Second) - defer t.Stop() - var last string - for { - select { - case <-ctx.Done(): - return - case <-t.C: - content, ok, err := a.zones.Data(ctx, "address-overrides.rpz") - if err != nil || !ok || content == last { - continue - } - a.overrideMu.Lock() - a.blocks.SetManagedRPZ(content) - if _, err := a.blocks.RefreshAll(ctx); err == nil { - if a.cache != nil { - a.cache.Flush() - } - last = content - } - a.overrideMu.Unlock() - } - } -} diff --git a/internal/app/zones.go b/internal/app/zones.go index 4c1a9d2..c80acf1 100644 --- a/internal/app/zones.go +++ b/internal/app/zones.go @@ -1,38 +1,22 @@ 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. +// The resolver only reads zones. Master files from blocklist.local_zones and the +// rendered DNSZone objects are compiled into one store; editing is the +// console's job and happens on the objects. // listZones renders the compiled store for the console. func (a *App) listZones() (any, error) { - dir, writable := a.managedZoneDir(), a.managedZones.Writable() - if a.kube != nil { - // With the Kubernetes source the zones that can be edited are the - // DNSZone objects, so those are the "managed" ones. The runtime itself - // cannot write them; the console, which can, reports writable. - dir, writable = a.kubeZoneDir(), false - } + // The zones that can be edited are the DNSZone objects, which the Kubernetes + // source renders into a directory of its own. The resolver itself writes + // nothing; the console, which can, reports the zones writable. + dir := a.kubeZoneDir() 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. @@ -40,7 +24,7 @@ func (a *App) listZones() (any, error) { Zones: []api.Zone{}, Files: []string{}, ManagedDir: dir, - Writable: writable, + Writable: false, Types: localzone.EditableTypes(), } if store != nil { @@ -55,193 +39,6 @@ func (a *App) listZones() (any, error) { 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 { diff --git a/internal/config/config.go b/internal/config/config.go index edb2e85..69c9534 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -316,15 +316,6 @@ 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/kubezones/client.go b/internal/kubezones/client.go deleted file mode 100644 index c63cd06..0000000 --- a/internal/kubezones/client.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package kubezones stores UI-managed zone files in a Kubernetes ConfigMap. -// The ConfigMap volume is shared by every fancy-dns replica, so UI edits do -// not depend on which pod receives the request. -package kubezones - -import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os" - "strings" - "time" -) - -const ( - serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount" - defaultName = "fancy-dns-zones" -) - -// Zone is a ConfigMap key and its master-file contents. -type Zone struct { - Name string `json:"name"` - Content string `json:"content"` -} - -// Client is a narrow client for the one ConfigMap the console may edit. -type Client struct { - baseURL string - ns string - name string - http *http.Client -} - -// InCluster returns nil outside Kubernetes. The projected service account -// token must be available; RBAC limits it to the fancy-dns-zones ConfigMap. -func InCluster() (*Client, error) { - name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP") - if name == "" { - name = defaultName - } - return InClusterConfigMap(name) -} - -// InClusterConfigMap returns a client for a named ConfigMap. RBAC on the -// service account still limits which ConfigMaps it can read or patch. -func InClusterConfigMap(name string) (*Client, error) { - host := os.Getenv("KUBERNETES_SERVICE_HOST") - if host == "" { - return nil, nil - } - port := os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS") - if port == "" { - port = "443" - } - ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE")) - if ns == "" { - b, err := os.ReadFile(serviceAccountDir + "/namespace") - if err != nil { - return nil, fmt.Errorf("kube zones: read service account namespace: %w", err) - } - ns = strings.TrimSpace(string(b)) - } - ca, err := os.ReadFile(serviceAccountDir + "/ca.crt") - if err != nil { - return nil, fmt.Errorf("kube zones: read service account CA: %w", err) - } - pool, err := x509.SystemCertPool() - if err != nil || pool == nil { - pool = x509.NewCertPool() - } - if !pool.AppendCertsFromPEM(ca) { - return nil, errors.New("kube zones: invalid service account CA") - } - if strings.TrimSpace(name) == "" { - return nil, errors.New("kube zones: ConfigMap name is required") - } - return &Client{ - baseURL: "https://" + net.JoinHostPort(host, port), - ns: ns, - name: name, - http: &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - Proxy: nil, - TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "kubernetes.default.svc"}, - }, - }, - }, nil -} - -func (c *Client) resourceURL() string { - return c.baseURL + "/api/v1/namespaces/" + url.PathEscape(c.ns) + "/configmaps/" + url.PathEscape(c.name) -} - -// List returns all zone files stored in the managed ConfigMap. -func (c *Client) List(ctx context.Context) ([]Zone, error) { - cm, _, err := c.get(ctx) - if err != nil { - return nil, err - } - out := make([]Zone, 0, len(cm.Data)) - for name, content := range cm.Data { - if strings.HasPrefix(name, "zone--") && strings.HasSuffix(name, ".zone") { - out = append(out, Zone{Name: strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone"), Content: content}) - } - } - return out, nil -} - -// Save updates or adds one zone file. Kubernetes resourceVersion makes the -// update conditional and retrying resource-version conflicts. Separate zone -// edits merge safely; when two editors change the same zone, the last save -// wins. -func (c *Client) Save(ctx context.Context, name, content string) error { - return c.change(ctx, filename(name), &content) -} - -// Delete removes one zone file. -func (c *Client) Delete(ctx context.Context, name string) error { - return c.change(ctx, filename(name), nil) -} - -// Data reads one arbitrary ConfigMap data key. It is used for console-owned -// shared files that are not zone master files. -func (c *Client) Data(ctx context.Context, key string) (string, bool, error) { - if c == nil { - return "", false, errors.New("kube zones: Kubernetes ConfigMap is unavailable") - } - cm, _, err := c.get(ctx) - if err != nil { - return "", false, err - } - value, ok := cm.Data[key] - return value, ok, nil -} - -// SaveData creates or replaces one arbitrary ConfigMap data key, retrying -// resource-version conflicts so independent edits to different keys merge. -func (c *Client) SaveData(ctx context.Context, key, content string) error { - if c == nil { - return errors.New("kube zones: Kubernetes ConfigMap is unavailable") - } - if key == "" || len(key) > 253 || strings.ContainsAny(key, `/\\`) { - return errors.New("kube zones: invalid ConfigMap data key") - } - if len(content) > 512*1024 { - return errors.New("kube zones: ConfigMap data exceeds 512 KiB") - } - return c.changeData(ctx, key, &content) -} - -func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" } - -func (c *Client) change(ctx context.Context, name string, content *string) error { - if !validKey(name) { - return errors.New("zone filename must be a DNS name ending in .zone") - } - return c.changeData(ctx, name, content) -} - -func (c *Client) changeData(ctx context.Context, name string, content *string) error { - if content != nil && len(*content) > 512*1024 { - return errors.New("ConfigMap data exceeds 512 KiB") - } - for attempt := 0; attempt < 3; attempt++ { - _, rv, err := c.get(ctx) - if err != nil { - return err - } - var value any - if content == nil { - value = nil - } else { - value = *content - } - body, err := json.Marshal(map[string]any{ - "metadata": map[string]string{"resourceVersion": rv}, - "data": map[string]any{name: value}, - }) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.resourceURL(), bytes.NewReader(body)) - if err != nil { - return err - } - if err := c.authorize(req); err != nil { - return err - } - req.Header.Set("Content-Type", "application/merge-patch+json") - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("kube zones: update ConfigMap: %w", err) - } - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) - resp.Body.Close() - if resp.StatusCode == http.StatusConflict { - continue - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("kube zones: update ConfigMap: Kubernetes returned %s", resp.Status) - } - return nil - } - return errors.New("kube zones: concurrent edits; reload and try again") -} - -type configMap struct { - Metadata struct { - ResourceVersion string `json:"resourceVersion"` - } `json:"metadata"` - Data map[string]string `json:"data"` -} - -func (c *Client) get(ctx context.Context) (configMap, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.resourceURL(), nil) - if err != nil { - return configMap{}, "", err - } - if err := c.authorize(req); err != nil { - return configMap{}, "", err - } - resp, err := c.http.Do(req) - if err != nil { - return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: Kubernetes returned %s", resp.Status) - } - var cm configMap - if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&cm); err != nil { - return configMap{}, "", fmt.Errorf("kube zones: decode ConfigMap: %w", err) - } - if cm.Data == nil { - cm.Data = map[string]string{} - } - return cm, cm.Metadata.ResourceVersion, nil -} - -func (c *Client) authorize(req *http.Request) error { - token, err := os.ReadFile(serviceAccountDir + "/token") - if err != nil { - return fmt.Errorf("kube zones: read service account token: %w", err) - } - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) - return nil -} - -func validKey(name string) bool { - if !strings.HasPrefix(name, "zone--") || !strings.HasSuffix(name, ".zone") || - strings.ContainsAny(name, `/\\`) || len(name) > 253 { - return false - } - name = strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone") - if name == "" || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { - return false - } - for _, label := range strings.Split(name, ".") { - if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return false - } - for _, r := range label { - if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { - return false - } - } - } - return true -} diff --git a/internal/localzone/managed_test.go b/internal/localzone/managed_test.go deleted file mode 100644 index 4eb0d20..0000000 --- a/internal/localzone/managed_test.go +++ /dev/null @@ -1,704 +0,0 @@ -package localzone - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/miekg/dns" -) - -// loadManaged compiles a managed directory the way the app does, so a test -// asserts on what the query path would actually serve rather than on the -// writer's own idea of it. -func loadManaged(t *testing.T, dir string) *Store { - t.Helper() - store, err := Load([]string{dir}, Options{}) - if err != nil { - t.Fatalf("load managed dir: %v", err) - } - return store -} - -func mustAdd(t *testing.T, m *Managed, origin string, rec Record, def ZoneDefaults) uint32 { - t.Helper() - serial, err := m.Add(origin, rec, def) - if err != nil { - t.Fatalf("add %s %s: %v", rec.Name, rec.Type, err) - } - return serial -} - -func zoneDef() ZoneDefaults { - return ZoneDefaults{NS: []string{"ns1.example.com."}} -} - -func TestManagedAddWritesAParsableZoneFile(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - first := mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{}) - - raw, err := os.ReadFile(filepath.Join(dir, "example.com.zone")) - if err != nil { - t.Fatalf("read zone file: %v", err) - } - text := string(raw) - for _, want := range []string{ - "$ORIGIN example.com.", - "$TTL 5m", - "IN SOA", - "IN NS", - "IN A", - "10.0.0.1", - } { - if !strings.Contains(text, want) { - t.Errorf("zone file is missing %q:\n%s", want, text) - } - } - - // The second write bumps the serial: a zone file that does not move its - // serial tells nobody the data changed. - second := mustAdd(t, m, "example.com", Record{Name: "db.example.com", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{}) - if second != first+1 { - t.Errorf("serial after second write = %d, want %d", second, first+1) - } - - store := loadManaged(t, dir) - if got := store.Zones(); len(got) != 1 || got[0] != "example.com." { - t.Fatalf("zones = %v, want [example.com.]", got) - } - res := store.Lookup(dns.Question{Name: "www.example.com.", Qtype: dns.TypeA}) - if !res.Found || len(res.Records) != 1 { - t.Fatalf("lookup www = %+v, want one A record", res) - } - if a, ok := res.Records[0].(*dns.A); !ok || a.A.String() != "10.0.0.1" { - t.Errorf("www A = %v, want 10.0.0.1", res.Records[0]) - } - // A record written without a TTL inherits the file's $TTL, not the - // library default of 3600. - if ttl := res.Records[0].Header().Ttl; ttl != 60 { - t.Errorf("www TTL = %d, want 60", ttl) - } -} - -func TestManagedSnapshotMarksTheConsoleAsTheSource(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "www.example.com", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{}) - - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - if zone == nil { - t.Fatal("snapshot is nil") - } - if !zone.Managed { - t.Error("zone is not marked managed, so the console would refuse to edit it") - } - if len(zone.Records) != 2 || zone.RecordCount != 2 { - t.Fatalf("records = %d, want 2", zone.RecordCount) - } - if zone.Serial == 0 { - t.Error("snapshot has no SOA serial") - } - for _, rec := range zone.Records { - if !rec.Managed { - t.Errorf("record %s %s is not marked managed", rec.Name, rec.Type) - } - if !strings.HasPrefix(rec.Source, dir) { - t.Errorf("record %s %s source = %q, want a path under %s", rec.Name, rec.Type, rec.Source, dir) - } - if rec.Delegation { - t.Errorf("the apex NS set is reported as a delegation: %+v", rec) - } - } - if len(zone.Delegations) != 0 { - t.Errorf("delegations = %+v, want none", zone.Delegations) - } -} - -// An operator's own master file is not the console's to rewrite, so its -// records have to be visible with Managed false. -func TestManagedSnapshotOfAnOperatorFileIsNotManaged(t *testing.T) { - dir := t.TempDir() - managed := NewManaged(dir) - mustAdd(t, managed, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - - op := filepath.Join(t.TempDir(), "example.com") - if err := os.WriteFile(op, []byte("$ORIGIN example.com.\n$TTL 300\n@ 300 IN SOA ns1 hostmaster 1 3600 600 604800 300\n@ 300 IN NS ns1\nhost 300 IN A 192.0.2.7\n"), 0o644); err != nil { - t.Fatal(err) - } - store, err := Load([]string{dir, op}, Options{}) - if err != nil { - t.Fatalf("load: %v", err) - } - zone := store.Snapshot("example.com.", dir) - if zone.Managed { - t.Error("a zone with an operator-managed record is reported as wholly managed") - } - var found bool - for _, rec := range zone.Records { - if rec.Name == "host.example.com." { - found = true - if rec.Managed { - t.Error("the operator's record is marked managed") - } - if rec.Value != "192.0.2.7" { - t.Errorf("operator record value = %q", rec.Value) - } - } - } - if !found { - t.Errorf("the operator's record is missing from the snapshot: %+v", zone.Records) - } -} - -func TestManagedDelegationIsACutBelowTheApex(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - - if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil { - t.Fatalf("set delegation: %v", err) - } - store := loadManaged(t, dir) - zone := store.Snapshot("example.com.", dir) - if len(zone.Delegations) != 1 { - t.Fatalf("delegations = %+v, want one", zone.Delegations) - } - cut := zone.Delegations[0] - if cut.Name != "sub.example.com." { - t.Errorf("delegated name = %q", cut.Name) - } - if strings.Join(cut.Targets, ",") != "ns1.sub.example.com.,ns2.sub.example.com." { - t.Errorf("delegation targets = %v", cut.Targets) - } - if !cut.Managed { - t.Error("delegation is not marked managed") - } - - // A cut means the parent answers nothing below it, and the store is what - // decides that. - res := store.Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}) - if !res.Delegated { - t.Errorf("lookup below the cut = %+v, want Delegated", res) - } - // The NS RRset at the cut itself stays answerable, or a resolver can - // never learn the delegation (RFC 1034 §4.3.3). - res = store.Lookup(dns.Question{Name: "sub.example.com.", Qtype: dns.TypeNS}) - if !res.Found || len(res.Records) != 2 { - t.Errorf("NS at the cut = %+v, want two records", res) - } - // Above the cut the parent still answers for itself. - if res := store.Lookup(dns.Question{Name: "example.com.", Qtype: dns.TypeNS}); !res.Found { - t.Error("apex NS lookup stopped working after a delegation was added") - } -} - -func TestManagedDelegationRejectsTheApexAndEmptyTargets(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - - if _, err := m.SetDelegation("example.com", "example.com", []string{"ns1.example.com."}, 300, ZoneDefaults{}); err == nil { - t.Error("delegating the apex was accepted") - } - if _, err := m.SetDelegation("example.com", "sub.example.com", nil, 300, ZoneDefaults{}); err == nil { - t.Error("a delegation with no targets was accepted") - } - if _, err := m.SetDelegation("example.com", "elsewhere.test", []string{"ns1.test."}, 300, ZoneDefaults{}); err == nil { - t.Error("a delegation outside the zone was accepted") - } -} - -func TestManagedRemoveDelegationDropsTheWholeCut(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com.", "ns2.sub.example.com."}, 300, ZoneDefaults{}); err != nil { - t.Fatal(err) - } - if _, removed, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil || !removed { - t.Fatalf("remove delegation: removed=%v err=%v", removed, err) - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - if len(zone.Delegations) != 0 { - t.Errorf("delegations = %+v, want none left", zone.Delegations) - } - // The parent answers for the name again once the cut is gone. - if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); res.Delegated { - t.Error("the name is still delegated after the cut was removed") - } - // A second removal is a stale console, not a server error. - if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err == nil { - t.Error("removing a delegation twice was accepted") - } -} - -func TestManagedPutReplacesTheRRset(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - for _, target := range []string{"10 mx1.example.com.", "20 mx2.example.com."} { - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: target}, ZoneDefaults{}) - } - if _, err := m.Put("example.com", Record{Name: "example.com", Type: "MX", TTL: 300, Value: "10 mx1.example.com."}, ZoneDefaults{}); err != nil { - t.Fatalf("put: %v", err) - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - var mx int - for _, rec := range zone.Records { - if rec.Type == "MX" { - mx++ - } - } - if mx != 1 { - t.Errorf("MX records = %d, want the whole RRset replaced by one", mx) - } -} - -func TestManagedDeleteIsScopedToTheRRsetOrTheRecord(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"one"`}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "TXT", TTL: 300, Value: `"two"`}, ZoneDefaults{}) - - if _, removed, err := m.Delete("example.com", "example.com", "TXT", `"two"`); err != nil || !removed { - t.Fatalf("delete one TXT: removed=%v err=%v", removed, err) - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - var txt int - for _, rec := range zone.Records { - if rec.Type == "TXT" { - txt++ - } - } - if txt != 1 { - t.Errorf("TXT records = %d, want the one that was not deleted", txt) - } - if _, _, err := m.Delete("example.com", "example.com", "TXT", ""); err != nil { - t.Fatalf("delete the RRset: %v", err) - } - if _, removed, err := m.Delete("example.com", "example.com", "TXT", ""); err == nil || removed { - t.Error("deleting an absent record succeeded") - } -} - -func TestManagedRefusesWhatItCannotRoundTrip(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - seed := Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."} - mustAdd(t, m, "example.com", seed, ZoneDefaults{}) - - cases := []struct { - what string - rec Record - }{ - {"out of zone", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.1"}}, - {"unknown type", Record{Name: "example.com", Type: "BOGUS", Value: "x"}}, - {"SOA", Record{Name: "example.com", Type: "SOA", Value: "a. b. 1 2 3 4 5"}}, - {"empty value", Record{Name: "example.com", Type: "A", Value: ""}}, - {"malformed rdata", Record{Name: "example.com", Type: "A", Value: "not-an-address"}}, - {"empty name", Record{Name: "", Type: "A", Value: "10.0.0.1"}}, - } - for _, tc := range cases { - if _, err := m.Add("example.com", tc.rec, ZoneDefaults{}); err == nil { - t.Errorf("%s was accepted", tc.what) - } - } - // A duplicate is refused rather than written twice, and neither attempt - // touched the file. - if _, err := m.Add("example.com", seed, ZoneDefaults{}); err == nil { - t.Error("a duplicate record was accepted") - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - if len(zone.Records) != 1 { - t.Errorf("records = %d, want only the seed record", len(zone.Records)) - } -} - -func TestManagedRefusesABadZoneApex(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - for _, origin := range []string{"", ".", "example", "example..com."} { - if _, err := m.Add(origin, Record{Name: "example.com.", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil { - t.Errorf("origin %q was accepted", origin) - } - } - // The one shape that is a zone: an apex with at least two labels. - if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{NS: []string{"ns1.example.com."}}); err != nil { - t.Fatalf("valid origin refused: %v", err) - } -} - -// A zone with no SOA cannot say NXDOMAIN, and one with no apex NS has no -// server to name, so the writer refuses to create either. -func TestManagedRefusesAZoneWithNoServerList(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}) - if err == nil { - t.Fatal("a brand new zone with no NS record was created") - } - if !strings.Contains(err.Error(), "NS") { - t.Errorf("error = %v, want it to say what is missing", err) - } - if _, statErr := os.Stat(filepath.Join(dir, "example.com.zone")); statErr == nil { - t.Error("a refused write left a zone file behind") - } -} - -func TestManagedWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - // A wildcard owner and a relative RDATA name: both are the kind of thing - // an operator types and a writer has to preserve. - mustAdd(t, m, "example.com", Record{Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"}, ZoneDefaults{}) - - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - byKey := map[string]Record{} - for _, rec := range zone.Records { - byKey[rec.Key()] = rec - } - wild, ok := byKey["*.example.com.|A"] - if !ok { - t.Fatalf("the wildcard record was lost: %+v", zone.Records) - } - if !wild.Wildcard { - t.Error("the wildcard record is not flagged as one") - } - if wild.Value != "10.0.0.9" { - t.Errorf("wildcard value = %q", wild.Value) - } - cname, ok := byKey["alias.example.com.|CNAME"] - if !ok { - t.Fatalf("the CNAME was lost: %+v", zone.Records) - } - if cname.Value != "target.example.com." { - t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value) - } - // And the store still answers the wildcard, which is the point of it. - res := loadManaged(t, dir).Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA}) - if !res.Found || len(res.Records) != 1 { - t.Errorf("wildcard lookup = %+v, want an answer", res) - } -} - -func TestManagedSeedsFromAnExistingZone(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - // The apex and SOA come from a zone that already exists elsewhere, so - // the console can add a record to it without the caller restating them. - _, err := m.Add("example.com", Record{Name: "new.example.com", Type: "A", Value: "10.0.0.5"}, ZoneDefaults{ - TTL: 120, - Soa: "ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300", - NS: []string{"ns1.example.com.", "ns2.example.com."}, - }) - if err != nil { - t.Fatalf("add with seeded zone: %v", err) - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - if zone.Serial != 2024010102 { - t.Errorf("serial = %d, want the seeded SOA's serial bumped once by the write", zone.Serial) - } - if zone.DefaultTTL != 120 { - t.Errorf("default TTL = %d, want the seeded 120", zone.DefaultTTL) - } - var ns int - for _, rec := range zone.Records { - if rec.Name == "example.com." && rec.Type == "NS" { - ns++ - } - } - if ns != 2 { - t.Errorf("apex NS records = %d, want the two seeded", ns) - } -} - -// Every type the API accepts has to survive being written and read back. A -// type in the whitelist that the master-file assembler does not recognise -// would render a line no parse accepts, and the writer's round-trip check -// would refuse the write for every record of that type. -func TestEveryEditableTypeSurvivesTheFile(t *testing.T) { - samples := map[string]string{ - "A": "10.0.0.1", "AAAA": "2001:db8::1", "CAA": `0 issue "letsencrypt.org"`, - "CNAME": "target.example.com.", "DHCID": "AAIBY2/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=", - "HINFO": `"cpu" "os"`, "HTTPS": "1 . alpn=h2", "LOC": "42 21 54 N 71 06 18 W -24m 30m 0m 10m", - // NAPTR's flags, service and regexp are all character-strings in - // miekg's presentation format, so all three are quoted; a bare - // regexp (as in RFC 3403 §4.1) does not parse. - "MX": "10 mail.example.com.", - "NAPTR": `100 50 "S" "SIP+D2U" "!^.*$!sip:info@example.com!" _sip._udp.example.com.`, - "NS": "ns1.example.com.", "PTR": "host.example.com.", - "SRV": "0 1 443 target.example.com.", "SPF": `"v=spf1 -all"`, - "SSHFP": "1 1 aa11223344556666778899aabbccddeeff00112233445566778899aabbccddeeff", - "SVCB": "1 target.example.com. alpn=h2", "TXT": `"hello" "world"`, - // miekg presents URI's target as a character-string, so it has to be - // quoted; the samples above pin that. - "URI": `10 1 "https://example.com/"`, - } - for _, rrtype := range EditableTypes() { - sample, ok := samples[rrtype] - if !ok { - t.Errorf("editable type %s has no sample in this test; the writer is untested for it", rrtype) - continue - } - dir := t.TempDir() - m := NewManaged(dir) - if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil { - t.Fatalf("%s: seed zone: %v", rrtype, err) - } - if _, err := m.Add("example.com", Record{Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample}, ZoneDefaults{}); err != nil { - t.Errorf("%s: add: %v", rrtype, err) - continue - } - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - var found *Record - for i := range zone.Records { - if zone.Records[i].Name == "rec.example.com." && zone.Records[i].Type == rrtype { - found = &zone.Records[i] - } - } - if found == nil { - t.Errorf("%s: the record did not survive the file: %+v", rrtype, zone.Records) - continue - } - // The canonical value is what the API echoed, so the console shows - // the same text the file holds. - if found.TTL != 300 { - t.Errorf("%s: TTL = %d, want 300", rrtype, found.TTL) - } - } -} - -func TestManagedWritableAndPath(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested", "zones") - m := NewManaged(dir) - if !m.Writable() { - t.Error("a directory under a writable parent is not writable") - } - // Asking must not touch the disk: the console calls this on every read. - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Errorf("checking writability created the directory (stat err = %v)", err) - } - if _, err := m.Add("example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}); err != nil { - t.Fatalf("add: %v", err) - } - if !m.Writable() { - t.Error("a directory the process just wrote into is not writable") - } - if got := m.Path("example.com."); got != filepath.Join(dir, "example.com.zone") { - t.Errorf("Path = %q", got) - } - if got := NewManaged("").Writable(); got { - t.Error("an unconfigured managed directory is writable") - } - // The probe must not leave files behind. - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 || entries[0].Name() != "example.com.zone" { - names := make([]string, 0, len(entries)) - for _, e := range entries { - names = append(names, e.Name()) - } - t.Errorf("managed directory holds %v, want only the zone file", names) - } -} - -// The console's form asks for a short name, because a zone is already chosen. -// A name without a trailing dot therefore means "in this zone", the same way -// it does in a zone file. -func TestManagedQualifiesARelativeOwnerName(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"}, ZoneDefaults{}) - mustAdd(t, m, "example.com", Record{Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"}, ZoneDefaults{}) - - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - got := map[string]string{} - for _, rec := range zone.Records { - got[rec.Name] = rec.Value - } - if got["www.example.com."] != "10.0.0.1" { - t.Errorf("relative owner became %v, want www.example.com.", got) - } - if got["*.cdn.example.com."] != "10.0.0.2" { - t.Errorf("relative wildcard owner became %v, want *.cdn.example.com.", got) - } - // And the store serves them under the qualified names. - if res := loadManaged(t, dir).Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found { - t.Error("the wildcard does not answer under its qualified name") - } - // Deleting by the same short name finds the same record. - if _, removed, err := m.Delete("example.com", "www", "A", ""); err != nil || !removed { - t.Errorf("delete by relative name: removed=%v err=%v", removed, err) - } - // An absolute name outside the zone is still refused. - if _, err := m.Add("example.com", Record{Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}, ZoneDefaults{}); err == nil { - t.Error("an absolute name outside the zone was accepted") - } -} - -// A zone whose SOA names a server that is not in its own NS set hands out a -// referral no resolver can follow, and the console is what changes that set. -func TestManagedSOAPrimaryFollowsTheApexNSSet(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - zone := loadManaged(t, dir).Snapshot("example.com.", dir) - if !strings.HasPrefix(zone.Soa, "ns1.example.com. ") { - t.Fatalf("soa = %q, want ns1.example.com. as the primary", zone.Soa) - } - if _, err := m.Put("example.com", Record{Name: "example.com", Type: "NS", TTL: 300, Value: "ns2.example.com."}, ZoneDefaults{}); err != nil { - t.Fatal(err) - } - zone = loadManaged(t, dir).Snapshot("example.com.", dir) - if !strings.HasPrefix(zone.Soa, "ns2.example.com. ") { - t.Errorf("soa = %q after replacing the apex NS set, want ns2.example.com.", zone.Soa) - } -} - -// A zone file that is not a zone file must be reported, not silently -// rewritten. -func TestManagedRefusesAFileWhoseOriginMoved(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - if err := os.Rename(m.Path("example.com."), m.Path("other.com.")); err != nil { - t.Fatal(err) - } - if _, err := m.Add("example.com", Record{Name: "www.example.com", Type: "A", Value: "10.0.0.1"}, ZoneDefaults{}); err == nil { - t.Error("a file holding another zone was overwritten") - } -} - -// A cut means the names under it belong to another server, so a record there -// is dead data: the store answers them as delegated without ever looking at -// what the zone file says. -func TestManagedRefusesDataBelowADelegation(t *testing.T) { - dir := t.TempDir() - m := NewManaged(dir) - mustAdd(t, m, "example.com", Record{Name: "example.com", Type: "NS", Value: "ns1.example.com."}, ZoneDefaults{}) - if _, err := m.SetDelegation("example.com", "sub.example.com", []string{"ns1.sub.example.com."}, 300, ZoneDefaults{}); err != nil { - t.Fatal(err) - } - - below := Record{Name: "host.sub.example.com", Type: "A", TTL: 60, Value: "10.0.0.3"} - if _, err := m.Add("example.com", below, ZoneDefaults{}); err == nil { - t.Error("a record below a delegation was accepted") - } - for _, rec := range loadManaged(t, dir).Snapshot("example.com.", dir).Records { - if rec.Name == below.Name { - t.Errorf("the refused record was written anyway: %+v", rec) - } - } - // The delegation itself is still there and still a cut. - if res := loadManaged(t, dir).Lookup(dns.Question{Name: "host.sub.example.com.", Qtype: dns.TypeA}); !res.Delegated { - t.Error("the cut stopped being a cut") - } - // And the rule follows the cut, not the record: removing the delegation - // makes the same record legal again. - if _, _, err := m.RemoveDelegation("example.com", "sub.example.com."); err != nil { - t.Fatal(err) - } - if _, err := m.Add("example.com", below, ZoneDefaults{}); err != nil { - t.Errorf("a record under a name that is no longer delegated was refused: %v", err) - } -} - -func TestZoneFileName(t *testing.T) { - for origin, want := range map[string]string{ - "example.com.": "example.com.zone", - "EXAMPLE.com.": "example.com.zone", - "example.com": "example.com.zone", - "a.b.example.": "a.b.example.zone", - "example.com...": "example.com...zone", - } { - if got := zoneFileName(origin); got != want { - t.Errorf("zoneFileName(%q) = %q, want %q", origin, got, want) - } - } -} - -func TestNormalizeOriginAndName(t *testing.T) { - if got, err := NormalizeOrigin(" Example.COM "); err != nil || got != "example.com." { - t.Errorf("NormalizeOrigin = %q, %v", got, err) - } - if got, err := NormalizeName("*.WWW.Example.com"); err != nil || got != "*.www.example.com." { - t.Errorf("NormalizeName = %q, %v", got, err) - } - if _, err := NormalizeName("."); err == nil { - t.Error("the root was accepted as an owner name") - } - // A name that is only a wildcard is not a zone the store can hold. - if _, err := NormalizeOrigin("*."); err == nil { - t.Error("a wildcard was accepted as a zone apex") - } -} - -func TestZoneFileIsHumanReadable(t *testing.T) { - f := &fileState{ - origin: "example.com.", - ttl: 300, - soa: mustSOA(t, "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300"), - recs: []Record{ - {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, - {Name: "example.com.", Type: "NS", TTL: 300, Value: "ns1.example.com."}, - }, - } - text := f.render() - if !strings.Contains(text, "@") || !strings.Contains(text, "IN SOA") { - t.Errorf("the apex SOA is not written with @:\n%s", text) - } - // One record per line, with the owner relative to the origin: that is the - // form this project's master-file parser accepts, so what the writer - // emits is what the loader can read. A bare owner line would parse as a - // record with no type. - for _, line := range strings.Split(text, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "$") { - continue - } - if !strings.Contains(line, " IN ") { - t.Errorf("record line without an owner, TTL, class and type: %q\n%s", line, text) - } - } - if !strings.Contains(text, "1m IN A") || !strings.Contains(text, "10.0.0.1") { - t.Errorf("the A record is missing:\n%s", text) - } - // And it is a zone file: it parses, and it parses to the same records. - parsed, err := parseFileState("example.com.", text) - if err != nil { - t.Fatalf("rendered file does not parse: %v\n%s", err, text) - } - if err := sameRecords(parsed.recs, sortCopy(f.recs)); err != nil { - t.Errorf("rendered file differs from the records: %v\n%s", err, text) - } -} - -func sortCopy(in []Record) []Record { - out := append([]Record(nil), in...) - sortRecords(out) - return out -} - -func mustSOA(t *testing.T, line string) *dns.SOA { - t.Helper() - rr, err := dns.NewRR(line) - if err != nil { - t.Fatal(err) - } - soa, ok := rr.(*dns.SOA) - if !ok { - t.Fatalf("%q is not an SOA", line) - } - return soa -} diff --git a/internal/localzone/render.go b/internal/localzone/render.go index 68313ba..64eb0b8 100644 --- a/internal/localzone/render.go +++ b/internal/localzone/render.go @@ -53,7 +53,7 @@ func RenderZone(origin string, def ZoneDefaults, recs []Record, serial uint32) ( if err := sameRecords(check.recs, f.recs); err != nil { return "", fmt.Errorf("the zone file for %s did not render correctly: %w", origin, err) } - return strings.Replace(text, FileHeader, ZoneSourceHeader, 1), nil + return text, nil } // ZoneSourceHeader tops a rendered zone that an external object owns. diff --git a/internal/localzone/managed.go b/internal/localzone/zonestate.go similarity index 53% rename from internal/localzone/managed.go rename to internal/localzone/zonestate.go index 4b57276..0e47366 100644 --- a/internal/localzone/managed.go +++ b/internal/localzone/zonestate.go @@ -3,8 +3,6 @@ package localzone import ( "errors" "fmt" - "os" - "path/filepath" "slices" "sort" "strconv" @@ -16,22 +14,11 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) -// DefaultZoneTTL is the $TTL a console-written zone file gets when the request -// does not name one. It is also the SOA minimum, so a record written without a +// DefaultZoneTTL is the $TTL a rendered zone gets when its spec does not name +// one. It is also the SOA minimum, so a record written without a // TTL of its own inherits it. const DefaultZoneTTL uint32 = 300 -// FileHeader tops every console-written zone file. The file is rewritten -// whole on each edit, so hand-written commentary below it does not survive; -// operator-owned data belongs in blocklist.local_zones, which the API never -// touches. -const FileHeader = `; fancy-dns console-managed zone data. -; -; Rewritten by the admin API on every change: edits made here by hand are lost -; the next time a record in this zone is added, changed or deleted. Keep -; hand-maintained data in blocklist.local_zones instead. -` - // editableTypes are the RR types the API accepts. It is a subset of what the // master-file assembler recognises, and of the types a zone browser can put // in a form: SOA is zone metadata the writer owns, and AXFR/IXFR/ANY are not @@ -68,124 +55,6 @@ func isEditableType(rrtype string) bool { return false } -// Managed is the console-managed zone directory: the only zone data the admin -// API is allowed to rewrite. It is one file per apex, named after it, holding -// the SOA, the apex NS set and whatever records the console has written. A -// zone whose bulk lives in an operator's master file can still gain a managed -// file, because Load merges every file it is given under one apex. -type Managed struct { - dir string -} - -// NewManaged wraps a directory. The directory is created on the first write, -// never here: a read of a zone directory that does not exist is a legitimate -// state, not an error. -func NewManaged(dir string) *Managed { return &Managed{dir: dir} } - -// Dir is the managed directory. -func (m *Managed) Dir() string { - if m == nil { - return "" - } - return m.dir -} - -// Path is the file holding one apex's managed data. -func (m *Managed) Path(origin string) string { - if m == nil || m.dir == "" { - return "" - } - return filepath.Join(m.dir, zoneFileName(origin)) -} - -// ErrNotManaged is why a write did not happen because the console is not -// allowed to write zone data: no managed directory is configured, or the one -// that is cannot be written to. It is not "not found", so the API answers a -// delete with 400 for it and 404 for a record that simply is not there. -var ErrNotManaged = errors.New("local zone writes are disabled") - -// Check reports whether a write can proceed, naming which of the two reasons -// stopped it. -func (m *Managed) Check() error { - if m == nil || m.dir == "" { - return fmt.Errorf("%w: set blocklist.managed_zones to let the console write zone data", ErrNotManaged) - } - if !m.Writable() { - return fmt.Errorf("%w: the managed zone directory %s is not writable", ErrNotManaged, m.dir) - } - return nil -} - -// Remove deletes a zone's managed file, which is how a zone the console -// created stops being authoritative. It refuses a zone that also exists in an -// operator's master file: deleting that file would not remove the zone, and -// reporting success would be a lie. -func (m *Managed) Remove(origin string, wholeZoneIsManaged bool) (bool, error) { - canonical, err := NormalizeOrigin(origin) - if err != nil { - return false, err - } - if err := m.Check(); err != nil { - return false, err - } - if !wholeZoneIsManaged { - return false, fmt.Errorf("%s also has records outside %s; remove those first", canonical, m.dir) - } - path := m.Path(canonical) - if err := os.Remove(path); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} - -// Exists reports whether the managed directory is there. It is not an error -// when it is not: the directory holds no zones until the console writes one. -func (m *Managed) Exists() bool { - if m == nil || m.dir == "" { - return false - } - info, err := os.Stat(m.dir) - return err == nil && info.IsDir() -} - -// Writable reports whether a write can succeed: a directory that is (or whose -// nearest existing parent is) one the process may write to. It creates -// nothing, because the API calls it on every read to tell the console whether -// its edit controls are live. -func (m *Managed) Writable() bool { - if m == nil || m.dir == "" { - return false - } - dir := m.dir - for { - info, err := os.Stat(dir) - if err == nil { - if !info.IsDir() { - return false - } - probe, err := os.CreateTemp(dir, ".fancy-dns-probe") - if err != nil { - return false - } - name := probe.Name() - _ = probe.Close() - _ = os.Remove(name) - return true - } - if !os.IsNotExist(err) { - return false - } - parent := filepath.Dir(dir) - if parent == dir { - return false - } - dir = parent - } -} - // ZoneDefaults seeds a managed file for an apex that has none yet: the SOA and // apex NS set of a zone that already exists elsewhere, or the NS targets the // request itself supplies. @@ -199,172 +68,6 @@ type ZoneDefaults struct { NS []string } -// Add writes one record into a zone, creating the zone's managed file when it -// has none. The record is canonicalised first, so what the API echoes back is -// what a zone-file parse will produce. -func (m *Managed) Add(origin string, rec Record, def ZoneDefaults) (uint32, error) { - var serial uint32 - err := m.mutate(origin, def, func(f *fileState) error { - rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value) - if err != nil { - return err - } - for _, have := range f.recs { - if have.Key() == rec.Key() && have.Value == rec.Value { - return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, origin) - } - } - f.recs = append(f.recs, rec) - return nil - }, &serial) - return serial, err -} - -// Put replaces the whole RRset of one name and type. This is the console's -// edit: a name and type hold one logical record (an A, a set of MXs, a TXT -// RRset), and replacing it wholesale is what an operator means by "change -// this row". -func (m *Managed) Put(origin string, rec Record, def ZoneDefaults) (uint32, error) { - var serial uint32 - err := m.mutate(origin, def, func(f *fileState) error { - rec, err := newRecord(f.origin, rec.Name, rec.Type, ttlOr(rec.TTL, f.ttl), rec.Value) - if err != nil { - return err - } - kept := f.recs[:0] - for _, have := range f.recs { - if have.Key() == rec.Key() { - continue - } - kept = append(kept, have) - } - f.recs = append(kept, rec) - return nil - }, &serial) - return serial, err -} - -// Delete removes one record, or the whole RRset when value is empty. It -// reports whether anything was removed: "delete" on a name that is not there -// is a stale console, not a server error. -func (m *Managed) Delete(origin, name, rrtype, value string) (uint32, bool, error) { - var serial uint32 - var removed bool - err := m.mutate(origin, ZoneDefaults{}, func(f *fileState) error { - owner, err := ownerInZone(f.origin, name) - if err != nil { - return err - } - up := strings.ToUpper(strings.TrimSpace(rrtype)) - if !isEditableType(up) { - return fmt.Errorf("type %s cannot be written through the API", up) - } - // An empty value means "the whole RRset", so there is nothing to - // parse. - want := "" - if value != "" { - rec, err := newRecord(f.origin, owner, up, f.ttl, value) - if err != nil { - return err - } - want = rec.Value - } - key := owner + "|" + up - kept := f.recs[:0] - for _, have := range f.recs { - hit := have.Key() == key && (want == "" || have.Value == want) - if hit { - removed = true - continue - } - kept = append(kept, have) - } - f.recs = append([]Record(nil), kept...) - if !removed { - return fmt.Errorf("no managed record matches %s %s in %s", name, up, f.origin) - } - return nil - }, &serial) - if err != nil { - return 0, false, err - } - return serial, removed, nil -} - -// SetDelegation hands a name below the apex, and everything under it, to other -// servers: it writes the NS RRset of that name. -func (m *Managed) SetDelegation(origin, name string, targets []string, ttl uint32, def ZoneDefaults) (uint32, error) { - if len(targets) == 0 { - return 0, errors.New("a delegation needs at least one NS target") - } - cut, err := NormalizeName(name) - if err != nil { - return 0, fmt.Errorf("delegated name: %w", err) - } - if cut == strings.ToLower(dns.Fqdn(origin)) { - return 0, errors.New("a delegation must be below the zone apex; the apex NS set is the zone's own server list") - } - if def.TTL == 0 { - def.TTL = ttl - } - var serial uint32 - err = m.mutate(origin, def, func(f *fileState) error { - for _, target := range targets { - t, err := newRecord(f.origin, cut, "NS", f.ttl, target) - if err != nil { - return fmt.Errorf("delegation target %q: %w", target, err) - } - // Replace one target at a time, so re-submitting the form does - // not accumulate duplicates and a second target does not drop - // the first. - kept := make([]Record, 0, len(f.recs)+1) - for _, have := range f.recs { - if have.Key() == t.Key() && have.Value == t.Value { - continue - } - kept = append(kept, have) - } - f.recs = append(kept, t) - } - return nil - }, &serial) - return serial, err -} - -// RemoveDelegation deletes the NS RRset of a name, which is the whole cut. -func (m *Managed) RemoveDelegation(origin, name string) (uint32, bool, error) { - return m.Delete(origin, name, "NS", "") -} - -// mutate is the whole write path: read, change, bump the serial, render, prove -// the rendering parses back to the same records, then write atomically. -func (m *Managed) mutate(origin string, def ZoneDefaults, fn func(*fileState) error, serial *uint32) error { - if err := m.Check(); err != nil { - return err - } - canonical, err := NormalizeOrigin(origin) - if err != nil { - return err - } - f, err := m.load(canonical) - if err != nil { - return err - } - if f == nil { - f, err = newFileState(canonical, def) - if err != nil { - return err - } - } - if err := fn(f); err != nil { - return err - } - if err := f.finish(); err != nil { - return err - } - return m.save(f, serial) -} - // fileState is one managed file's content in presentation form. type fileState struct { origin string @@ -472,7 +175,7 @@ func (f *fileState) checkCuts() error { // indented under it. func (f *fileState) render() string { var sb strings.Builder - sb.WriteString(FileHeader) + sb.WriteString(ZoneSourceHeader) fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin) fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl)) soa := f.soa @@ -509,86 +212,6 @@ func sortedNames(byName map[string][]Record) []string { return out } -// save writes the file atomically, and only after proving that what it -// rendered is what a parse of that text produces. A writer bug would otherwise -// show up as a zone that silently stopped answering. -func (m *Managed) save(f *fileState, serial *uint32) error { - text := f.render() - check, err := parseFileState(f.origin, text) - if err != nil { - return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err) - } - if err := sameRecords(check.recs, f.recs); err != nil { - return fmt.Errorf("the zone file for %s did not render correctly: %w", f.origin, err) - } - if err := os.MkdirAll(m.dir, 0o755); err != nil { - return fmt.Errorf("managed zone directory %s: %w", m.dir, err) - } - path := m.Path(f.origin) - tmp, err := os.CreateTemp(m.dir, ".fancy-dns-zone-*") - if err != nil { - return fmt.Errorf("managed zone directory %s: %w", m.dir, err) - } - name := tmp.Name() - defer func() { - // A rename that never happened must not leave a stray temp file. - _ = os.Remove(name) - }() - if _, err := tmp.WriteString(text); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(name, 0o644); err != nil { - return err - } - if err := os.Rename(name, path); err != nil { - return err - } - if serial != nil && f.soa != nil { - *serial = f.soa.Serial - } - return nil -} - -// load reads one apex's managed file. A missing file is (nil, nil): the zone -// has no console-managed data yet, which is the normal state. -func (m *Managed) load(origin string) (*fileState, error) { - path := m.Path(origin) - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer f.Close() - z, err := zonefile.Parse(f, zonefile.Options{Origin: origin, File: path, BaseDir: m.dir}) - if err != nil { - return nil, err - } - if strings.ToLower(z.Origin) != origin { - return nil, fmt.Errorf("%s holds zone %s, not %s", path, z.Origin, origin) - } - return fileStateFromZone(z, origin), nil -} - -// ManagedRecords reads the records the console owns for one apex, which is the -// subset the API may rewrite. -func (m *Managed) ManagedRecords(origin string) ([]Record, error) { - f, err := m.load(origin) - if err != nil || f == nil { - return nil, err - } - return append([]Record(nil), f.recs...), nil -} - // fileStateFromZone converts a parsed master file into presentation form. func fileStateFromZone(z *zonefile.Zone, origin string) *fileState { ttl := z.DefaultTTL @@ -786,11 +409,6 @@ func relativeOwner(name, origin string) string { return name } -// zoneFileName maps an apex to its managed file. -func zoneFileName(origin string) string { - return strings.TrimSuffix(strings.ToLower(origin), ".") + ".zone" -} - // synthSOA builds a zone's start of authority. func synthSOA(origin, primary string, serial, ttl uint32) (*dns.SOA, error) { if primary == "" { diff --git a/internal/localzone/zonestate_test.go b/internal/localzone/zonestate_test.go new file mode 100644 index 0000000..2e1e1db --- /dev/null +++ b/internal/localzone/zonestate_test.go @@ -0,0 +1,267 @@ +package localzone + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/miekg/dns" +) + +// rendered renders a zone and loads it the way the resolver does, so a test +// asserts on what the query path would actually serve. +func rendered(t *testing.T, origin string, def ZoneDefaults, recs []Record) (*Store, string) { + t.Helper() + text, err := RenderZone(origin, def, recs, 7) + if err != nil { + t.Fatalf("render %s: %v", origin, err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "z.zone"), []byte(text), 0o600); err != nil { + t.Fatal(err) + } + store, err := Load([]string{dir}, Options{}) + if err != nil { + t.Fatalf("load rendered zone: %v\n%s", err, text) + } + return store, text +} + +func ns(origin string) Record { return Record{Name: origin, Type: "NS", Value: "ns1." + origin} } + +func TestRenderedZoneIsAParsableZoneFile(t *testing.T) { + store, text := rendered(t, "example.com", ZoneDefaults{}, []Record{ + ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, + }) + for _, want := range []string{"$ORIGIN example.com.", "$TTL 5m", "IN SOA", "IN NS", "IN A", "10.0.0.1", "Kubernetes DNSZone"} { + if !strings.Contains(text, want) { + t.Errorf("zone file is missing %q:\n%s", want, text) + } + } + zone := store.Snapshot("example.com.", "") + if zone == nil || zone.Serial != 7 { + t.Fatalf("zone = %+v, want the serial that was asked for", zone) + } + // The same input renders the same bytes: replicas must agree. + again, err := RenderZone("example.com", ZoneDefaults{}, []Record{ + ns("example.com."), {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, + }, 7) + if err != nil || again != text { + t.Fatalf("rendering is not deterministic (%v)", err) + } +} + +func TestWildcardAndRelativeTargetsSurviveTheRoundTrip(t *testing.T) { + store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ + ns("example.com."), + {Name: "*.example.com", Type: "A", TTL: 60, Value: "10.0.0.9"}, + {Name: "alias.example.com", Type: "CNAME", TTL: 60, Value: "target"}, + }) + zone := store.Snapshot("example.com.", "") + byKey := map[string]Record{} + for _, rec := range zone.Records { + byKey[rec.Key()] = rec + } + wild, ok := byKey["*.example.com.|A"] + if !ok || !wild.Wildcard || wild.Value != "10.0.0.9" { + t.Fatalf("the wildcard record = %+v (%v)", wild, ok) + } + if cname := byKey["alias.example.com.|CNAME"]; cname.Value != "target.example.com." { + t.Errorf("a relative CNAME target became %q, want it qualified by the origin", cname.Value) + } + if res := store.Lookup(dns.Question{Name: "anything.example.com.", Qtype: dns.TypeA}); !res.Found || len(res.Records) != 1 { + t.Errorf("wildcard lookup = %+v, want an answer", res) + } +} + +func TestEveryEditableTypeSurvivesTheFile(t *testing.T) { + samples := map[string]string{ + "A": "10.0.0.1", "AAAA": "2001:db8::1", "CAA": `0 issue "letsencrypt.org"`, + "CNAME": "target.example.com.", "DHCID": "AAIBY2/AuCccgoJbsaxcQc9TUapptP69lOjxfNuVAA2kjEA=", + "HINFO": `"cpu" "os"`, "HTTPS": "1 . alpn=h2", "LOC": "42 21 54 N 71 06 18 W -24m 30m 0m 10m", + "MX": "10 mail.example.com.", + "NAPTR": `100 50 "S" "SIP+D2U" "!^.*$!sip:info@example.com!" _sip._udp.example.com.`, + "NS": "ns1.example.com.", "PTR": "host.example.com.", + "SRV": "0 1 443 target.example.com.", "SPF": `"v=spf1 -all"`, + "SSHFP": "1 1 aa11223344556666778899aabbccddeeff00112233445566778899aabbccddeeff", + "SVCB": "1 target.example.com. alpn=h2", "TXT": `"hello" "world"`, + "URI": `10 1 "https://example.com/"`, + } + for _, rrtype := range EditableTypes() { + sample, ok := samples[rrtype] + if !ok { + t.Errorf("editable type %s has no sample in this test; the renderer is untested for it", rrtype) + continue + } + text, err := RenderZone("example.com", ZoneDefaults{}, []Record{ + ns("example.com."), {Name: "rec.example.com", Type: rrtype, TTL: 300, Value: sample}, + }, 1) + if err != nil { + t.Errorf("%s: render: %v", rrtype, err) + continue + } + zf, err := ParseZoneFile("example.com", text) + if err != nil { + t.Errorf("%s: the rendered file does not parse: %v", rrtype, err) + continue + } + found := false + for _, r := range zf.Records { + if r.Name == "rec.example.com." && r.Type == rrtype && r.TTL == 300 { + found = true + } + } + if !found { + t.Errorf("%s: the record did not survive the file: %+v", rrtype, zf.Records) + } + } +} + +func TestRelativeOwnersAreQualified(t *testing.T) { + store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ + ns("example.com."), + {Name: "www", Type: "A", TTL: 60, Value: "10.0.0.1"}, + {Name: "*.cdn", Type: "A", TTL: 60, Value: "10.0.0.2"}, + }) + got := map[string]string{} + for _, rec := range store.Snapshot("example.com.", "").Records { + got[rec.Name] = rec.Value + } + if got["www.example.com."] != "10.0.0.1" || got["*.cdn.example.com."] != "10.0.0.2" { + t.Errorf("owners = %v", got) + } + if res := store.Lookup(dns.Question{Name: "cdn.example.com.", Qtype: dns.TypeA}); !res.Found { + t.Error("the wildcard does not answer under its qualified name") + } + if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{ns("example.com."), {Name: "host.elsewhere.test.", Type: "A", Value: "10.0.0.9"}}, 1); err == nil { + t.Error("an absolute name outside the zone was accepted") + } +} + +// A zone whose SOA names a server that is not in its own NS set hands out a +// referral no resolver can follow. +func TestSOAPrimaryFollowsTheApexNSSet(t *testing.T) { + store, _ := rendered(t, "example.com", ZoneDefaults{}, []Record{ns("example.com.")}) + if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns1.example.com. ") { + t.Fatalf("soa = %q, want ns1 as the primary", soa) + } + store, _ = rendered(t, "example.com", ZoneDefaults{Soa: "ns1.example.com. hostmaster.example.com. 1 3600 600 604800 300"}, + []Record{{Name: "example.com.", Type: "NS", Value: "ns2.example.com."}}) + if soa := store.Snapshot("example.com.", "").Soa; !strings.HasPrefix(soa, "ns2.example.com. ") { + t.Errorf("soa = %q: a seed SOA must not name a server outside the NS set", soa) + } +} + +func TestRenderRefusesWhatItCannotServe(t *testing.T) { + for name, tc := range map[string]struct { + origin string + recs []Record + }{ + "no server list": {"example.com", []Record{{Name: "www.example.com.", Type: "A", Value: "10.0.0.1"}}}, + "bad apex": {"com", []Record{ns("com.")}}, + "root": {".", nil}, + "duplicate": {"example.com", []Record{ns("example.com."), ns("example.com.")}}, + "soa as record": {"example.com", []Record{ns("example.com."), {Name: "example.com.", Type: "SOA", Value: "a. b. 1 2 3 4 5"}}}, + "below a cut": {"example.com", []Record{ + ns("example.com."), {Name: "sub.example.com.", Type: "NS", Value: "ns.sub.example.com."}, + {Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"}, + }}, + } { + if _, err := RenderZone(tc.origin, ZoneDefaults{}, tc.recs, 1); err == nil { + t.Errorf("%s: accepted", name) + } + } + // The rule follows the cut, not the record. + if _, err := RenderZone("example.com", ZoneDefaults{}, []Record{ + ns("example.com."), {Name: "host.sub.example.com.", Type: "A", Value: "10.0.0.3"}, + }, 1); err != nil { + t.Errorf("a record under a name that is not delegated was refused: %v", err) + } +} + +func TestNormalizeOriginAndName(t *testing.T) { + if got, err := NormalizeOrigin(" Example.COM "); err != nil || got != "example.com." { + t.Errorf("NormalizeOrigin = %q, %v", got, err) + } + if got, err := NormalizeName("*.WWW.Example.com"); err != nil || got != "*.www.example.com." { + t.Errorf("NormalizeName = %q, %v", got, err) + } + if _, err := NormalizeName("."); err == nil { + t.Error("the root was accepted as an owner name") + } + if _, err := NormalizeOrigin("*."); err == nil { + t.Error("a wildcard was accepted as a zone apex") + } +} + +func TestZoneFileIsHumanReadable(t *testing.T) { + f := &fileState{ + origin: "example.com.", + ttl: 300, + soa: mustSOA(t, "example.com. 300 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 3600 600 604800 300"), + recs: []Record{ + {Name: "www.example.com.", Type: "A", TTL: 60, Value: "10.0.0.1"}, + {Name: "example.com.", Type: "NS", TTL: 300, Value: "ns1.example.com."}, + }, + } + text := f.render() + if !strings.Contains(text, "@") || !strings.Contains(text, "IN SOA") { + t.Errorf("the apex SOA is not written with @:\n%s", text) + } + // One record per line: this project's master-file parser takes a bare owner + // line as a record with no type. + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "$") { + continue + } + if !strings.Contains(line, " IN ") { + t.Errorf("record line without an owner, TTL, class and type: %q\n%s", line, text) + } + } + parsed, err := parseFileState("example.com.", text) + if err != nil { + t.Fatalf("rendered file does not parse: %v\n%s", err, text) + } + if err := sameRecords(parsed.recs, sortCopy(f.recs)); err != nil { + t.Errorf("rendered file differs from the records: %v\n%s", err, text) + } +} + +func TestParseZoneFile(t *testing.T) { + zf, err := ParseZoneFile("Home.Example.com", "$TTL 120\n@ IN SOA ns1 host 5 3600 600 604800 120\n@ IN NS ns1\nns1 IN A 10.0.0.1\nweb 30 IN A 10.0.0.2\n") + if err != nil { + t.Fatal(err) + } + if zf.TTL != 120 || !strings.HasPrefix(zf.Soa, "ns1.home.example.com. host.home.example.com. 5 ") || len(zf.Records) != 3 { + t.Fatalf("zone file = %+v", zf) + } + for _, r := range zf.Records { + if r.Name == "web.home.example.com." && r.TTL != 30 { + t.Errorf("web TTL = %d, want its own 30", r.TTL) + } + } + if _, err := ParseZoneFile("home.example.com", "@@@ ("); err == nil { + t.Error("garbage parsed") + } +} + +func sortCopy(in []Record) []Record { + out := append([]Record(nil), in...) + sortRecords(out) + return out +} + +func mustSOA(t *testing.T, line string) *dns.SOA { + t.Helper() + rr, err := dns.NewRR(line) + if err != nil { + t.Fatal(err) + } + soa, ok := rr.(*dns.SOA) + if !ok { + t.Fatalf("%q is not an SOA", line) + } + return soa +}