Files
fancy-dns/STATUS.md
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

7.7 KiB

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.