139 lines
7.7 KiB
Markdown
139 lines
7.7 KiB
Markdown
# 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.
|