# fancy-dns — status and continuation plan `AGENTS.md` holds the decisions, invariants and working rules. This file holds state and the plan. History is in `git log`; the old per-round fix lists were removed because each fix is explained in its commit and pinned by a test. Last updated after the round that added client-group validation. The cluster-object architecture, the separate console and the web UI are deployed. ## What exists Two binaries, one repository: | Binary | Does | Talks to | |---|---|---| | `fancy-dns` | The resolver: UDP/TCP/DoT/DoH, cache, blocklists, ACL groups, local zones, access log, metrics, a read-only admin API. Reads `DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride` and `DomainRule` objects with a watch (`-kube`) and keeps a snapshot so it can start without the cluster. | Upstreams; optionally the Kubernetes API and Valkey. | | `fancy-dns-console` | The web UI and the edit API. The only writer of the cluster objects; authenticates (API key, basic, OIDC with PKCE); proxies every other `/api` route to the resolver. Not on the DNS path. | The resolver's admin API and the Kubernetes API. | Operational docs: `README.md` (reference), `deploy/MIGRATION.md` (ConfigMap deployment to cluster objects), `deploy/OUTSIDE-CLUSTER.md` (a backup resolver and console on a node outside the cluster), `deploy/compose/README.md` (file-configured Docker backup). ## Verified `make check` (gofmt, vet, compile, `go test -race ./...`) passes on every package. The web console typechecks and `oxlint` reports no errors; the remaining warnings are `set-state-in-effect` in `AppShell`, `Login`, `Domains` and `Zones` and an unused parameter in `ui/Select.tsx`. The console has no test runner, so a UI change is verified by `tsc` and by looking at it. ## This round - **Client-group validation.** `POST /api/clients/validate` (console only) checks a group without saving it. It applies the same checks as a save, now reported per field and all at once (`kubesource.CheckGroupAll`, `kubesource.FieldError`), then runs `acl.Analyze`, which explains the group in plain English and warns about legal-but-unintended settings: a group buried under a wider one (and the reverse: a save that buries a later group), CIDRs with host bits or a huge range, an allowlist, `block_only`, a rate limit of 0 under a default limit, unknown upstreams, domain patterns that never match, tags with a dot. `test_ip` says which group an address would land in. Tests: `internal/acl/explain_test.go`, `internal/console/validate_test.go`. - **Clients page.** Rebuilt around that endpoint: templates for the common cases, sections ("Which devices?", "What happens to their queries?", advanced), live validation with errors next to the field, a "What this group will do" panel, and a try-an-address box. The "Match any client" switch is gone: the console cannot store a catch-all group (`match.any` alone is refused for cluster objects), so it could only produce an error. - **The old help text was wrong, in ways that mattered.** It said `allow_domains` "skip the blocklists"; in fact a non-empty list is allowlist-only mode (everything else is blocked). It said `block_only` means "no recursion"; in fact it is identical to `allow`. It suggested `.lan` as a reverse-DNS suffix, which never matched anything (see the next item). It said tags match "ECS/DNS tag metadata"; they are labels of the client's reverse name. README's `acl` table is corrected to match. - **`matchPTR` rejected a suffix with a leading dot.** `.lan.` was compared at a label boundary that the leading dot had already consumed, so it matched nothing. A leading dot now means "anything under". Pinned in `TestMatchPTRLabelBoundary`. This changes which clients a group with such a suffix matches (from nobody to the intended ones). - **Docs.** This file, `deploy/OUTSIDE-CLUSTER.md`, and removal of the dead `qlog` and `managed_zones` references from README, the Compose config, and a comment in `internal/api/zones.go`. ## Decisions waiting for you 1. ~~`block_only`.~~ **Decided: removed from the UI.** It did exactly what `allow` does. The API and CRD still accept it so stored objects keep working; the console shows it only on a group that already has it, and the validator warns. 2. ~~Catch-all group.~~ **Decided: no change.** "Everyone else" is the default policy in the resolver's YAML (`acl.default_policy`). The console does not set it and cluster objects are never catch-alls. 3. The pinned behaviours below, one at a time. ## Pinned, not fixed Each has a test that fails when it changes and says what to do then. - **Unknown config keys are silently ignored** (`TestLoadIgnoresUnknownKeys`). - **Auth config is frozen at startup.** `api.base_path`, `trusted_proxies` and the authenticator are read once; a reload re-reads CORS only (`TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart`, `TestCORSOriginsAreReadAtHandlerBuild`). - **DoH clients behind an Ingress all look like the proxy**, because DoH deliberately ignores `X-Forwarded-For` (`TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader`). - **`api.cors_origins: ["*"]` echoes the request's Origin with credentials** (`TestCORSWildcardEchoesTheOrigin`, `TestCORSPreflight`). - **A partial bind failure is invisible** to the admin API (`TestStartReportsAPartialFailureButServes`). - **A question-less message is answered on UDP and drops the connection on TCP/DoT** (`TestUDPQuestionlessMessageReachesTheEngine`). - **`doh.sent` is never bumped** (`TestCountersFollowTheEngineOutcome`). The README's "Known inconsistencies" section lists more dead config keys and stale line references; its line numbers have drifted and have not been re-audited. ## Plan, in order 1. Resolve the decisions above. 2. **Stop hand-writing the console's types.** `web/src/lib/types.ts` is hand-written and `apiFetch` ends in `JSON.parse(text) as T`; the Go contract tests make the server authoritative today, but only because someone compared them once. Generate the types from the Go structs (or emit an OpenAPI document). `internal/app/dump_shapes_test.go` (`DUMP_SHAPES=1`) is the stopgap. 3. **Test the console package beyond validation.** `internal/console` has one test file (the validator). The cluster-consistent merge in `handleClients` and `putACL` is the part that has caused lost updates before. 4. **Decide whether the shutdown grace belongs in config.** `main` hard-codes 15s for `Stop`, of which 250ms is the UDP drain, and the Kubernetes `terminationGracePeriodSeconds` is a second copy of the same number. 5. **Look at the Zones and Clients pages in a browser.** Neither the Zones page nor this round's Clients page has been seen rendered by the person who wrote it; both are typechecked and built only. 6. Re-audit README "Known inconsistencies" against the code. ## Commands - `make check-go`: the inner loop (~1s): gofmt, vet, compile (no npm). - `make check`: `check-go` plus `go test -race ./...`; what CI runs. - `make smoke`: boots the real binary with the console embedded and checks the API, metrics and auth. - `make web-check`: console typecheck and production build. - `make build`: both binaries with the console embedded (needs npm). - `make bench`: cache and blocklist benchmarks. ## Test-hygiene traps These are in `AGENTS.md` (TXT strings over 255 bytes, `EDNS0_NSID` is hex, `dns.NewRR` needs a TTL, closing a stop channel after `wg.Wait()`, counting goroutines by stack, `tsc -b` replaying cached diagnostics, `vite build` deleting `internal/web/dist/.gitkeep`). One more from this round: a Go test that needs a Kubernetes client should use `dynamic/fake` with `NewSimpleDynamicClientWithCustomListKinds` and list every GVR in `kubesource.allGVRs`, or a `List` panics.