1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
@@ -1,7 +1,9 @@
|
||||
# fancy-dns — development status and continuation plan
|
||||
|
||||
Last updated when the whole tree went green. `AGENTS.md` holds the decisions,
|
||||
invariants and working rules; this file holds state and the plan.
|
||||
Last updated after the round that finished the test coverage of every package
|
||||
that has logic worth covering, fixed two live bugs and pinned the open
|
||||
questions. `AGENTS.md` holds the decisions, invariants and working rules; this
|
||||
file holds state and the plan.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -28,6 +30,7 @@ A lightweight, high-performance recursive DNS server with:
|
||||
| k8s networking | No hostNetwork, LoadBalancer Service | the server never sets SO_REUSEPORT, so two host-network pods cannot share :53 |
|
||||
| Memory | Soft GOMEMLIMIT, cache byte budget | a cgroup kill is an outage; a cache miss is not |
|
||||
| Scale-out | StatefulSet, no HPA | an HPA cannot drive a StatefulSet |
|
||||
| Unknown YAML keys | Ignore, loudly documented | `KnownFields(true)` was considered and rejected as a behaviour change for anyone carrying extra keys. Pinned by `TestLoadIgnoresUnknownKeys`. |
|
||||
|
||||
## Package layout
|
||||
|
||||
@@ -48,7 +51,8 @@ internal/
|
||||
metrics/ Prometheus collectors + rolling time-series rings
|
||||
cluster/ Valkey pub/sub, leader lease, divergence detection
|
||||
api/ admin REST + SSE, auth, CORS, rate limiting
|
||||
app/ wiring, lifecycle, API hook implementations, memory envelope
|
||||
app/ wiring, lifecycle, API hook implementations, memory envelope,
|
||||
and the response contract the console is written against
|
||||
web/ go:embed of the built console
|
||||
web/ Vite + React + TS + Tailwind console
|
||||
deploy/ example config, example zones, k8s manifests
|
||||
@@ -66,221 +70,254 @@ Cache is a single shard-local lookup; only a miss touches the network.
|
||||
|
||||
## Status
|
||||
|
||||
### Green — the whole tree passes
|
||||
### Green, end to end
|
||||
|
||||
`gofmt` clean, `go build ./...` clean, `go vet` clean.
|
||||
`gofmt` clean, `go build ./...` clean, `go vet ./...` clean, and
|
||||
`go test -race -count=1 -timeout 300s ./...` green across every package.
|
||||
|
||||
`go test -race ./internal/... -count=1 -timeout 300s` is **green across every
|
||||
package that has a suite**: `acl`, `app`, `blocklist`, `cache`, `config`,
|
||||
`engine`, `localzone`, `metrics`, `qlog`, `resolver`, `zonefile` — roughly 230
|
||||
tests — and `api` joined the list with 78 cases of its own.
|
||||
| Package | top-level tests | notes |
|
||||
|---|---|---|
|
||||
| `internal/acl` | 26 | group compilation, matching, first-group-wins |
|
||||
| `internal/api` | 55 (156 cases) | the whole route table, three auth modes, CORS, rate limiting, SSE, settings, error branches, pinned limitations |
|
||||
| `internal/app` | 25 | wiring, hooks, the API response contract, integration |
|
||||
| `internal/blocklist` | 28 | four parsers, the set, the manager, the shipped example zone |
|
||||
| `internal/cache` | 27 | LRU, TTL policy, serve-stale, bbolt round-trip |
|
||||
| `internal/cluster` | in progress | Valkey lease, pub/sub, divergence |
|
||||
| `internal/config` | 22 | Duration/Size, `${VAR}`, unknown keys, the shipped example configs |
|
||||
| `internal/engine` | 26 | the pipeline end to end, plus the pins |
|
||||
| `internal/localzone` | 18 | wildcards, delegation, CNAME following |
|
||||
| `internal/metrics` | 10 | the metric names, which are the contract with the dashboards |
|
||||
| `internal/qlog` | 18 | ring, bbolt, top domains, sampling |
|
||||
| `internal/resolver` | 10 | pool, breakers, health, policies |
|
||||
| `internal/server` | 36 | four listeners, UDP deadline reset, truncation, DoH limits, identity, shutdown, stats |
|
||||
| `internal/zonefile` | 29 | the master-file parser and the shipped example zone |
|
||||
| `web` | 0 | typecheck and `oxlint` clean (0 errors), no test runner |
|
||||
| `cmd/fancy-dns` | 0 | exercised by `make smoke` and the CI smoke job instead |
|
||||
|
||||
`make check-go` is the inner loop (1.3 s: gofmt check, vet, compile). It uses
|
||||
`go-build`, not `build`, because `build` depends on `web` and shells out to npm.
|
||||
`make check` = `check-go test`.
|
||||
### The commands that matter
|
||||
|
||||
### No test suite yet
|
||||
- `make check-go` — the inner loop (~1.3 s): gofmt check, vet, compile. Uses
|
||||
`go-build`, not `build`, because `build` depends on `web` and shells out to npm.
|
||||
- `make check` — `check-go` + the suite. What CI runs.
|
||||
- `make smoke` — **new.** Boots the real binary with the console embedded on
|
||||
unprivileged ports (the same `sed` rewrite CI uses), then checks `-check`,
|
||||
`/api/healthz`, the embedded console, the unauthenticated 401, an
|
||||
authenticated `whoami` and `/api/metrics`. Catches a broken embed, a bad
|
||||
default and an auth regression in one command.
|
||||
- `make web-check` — console typecheck and production build.
|
||||
- `make bench` — cache and blocklist benchmarks.
|
||||
|
||||
`cluster`, `server`, `web`.
|
||||
### Test coverage is no longer the interesting gap
|
||||
|
||||
`internal/engine` is now covered — 23 tests over a harness (`harness_test.go`)
|
||||
that builds a wired engine with a fake upstream on loopback, a local zone, a
|
||||
blocklist, a cache and a query log — and `internal/config` has a `Size` suite.
|
||||
`internal/metrics` has a counter/gauge/exposition suite (9 tests) that pins the
|
||||
metric names, which are the contract with the Grafana dashboards and the
|
||||
ServiceMonitor. `internal/api` is covered too: every route in the table (each
|
||||
asserted to reach its hook, so a new route without a case is a route nothing
|
||||
tests), method/path rejection, a read-route sweep that asserts nothing mutated,
|
||||
panic recovery, the base path, all three auth modes, read-only keys and
|
||||
`allow_config_write: false`, loopback trust, and the hashed API keys.
|
||||
`internal/server` is the one in progress.
|
||||
The API **response contract** is. The console's types are hand-written and
|
||||
`apiFetch` ends in `JSON.parse(text) as T`, so nothing in the toolchain compared
|
||||
them with the Go structs; a field that lost its `json` tag, or a response a
|
||||
struct never had, showed up as an empty table in a browser and nowhere else.
|
||||
`internal/app` now has contract tests that assert the exact JSON key set of
|
||||
every admin endpoint — see the fix list.
|
||||
|
||||
## Fixed in the last round (the engine and `config.Size` work)
|
||||
## Fixed this round
|
||||
|
||||
- **`config.ParseSize` ignored the unit multiplier entirely.** `scaleSize` took
|
||||
the number and returned `Size(n)`; `mult` never entered the arithmetic, so
|
||||
`512MiB` parsed as **512 bytes** and `1GiB` as 1 byte. Every memory budget
|
||||
written in units — `memory.limit`, `memory.reserve`, `memory.cache_max_bytes`,
|
||||
the cache byte budget in the app wiring — was off by a factor of the unit,
|
||||
and `EffectiveLimit`'s 64 MiB floor then quietly replaced the operator's
|
||||
number. The memory envelope was inert for anyone who wrote a unit, which is
|
||||
everyone: a bare number is bytes. The fix is one multiplication;
|
||||
`size_test.go` now pins `1.5GiB`, `0.5KiB`, the unit shadowing cases (`MiB`
|
||||
must not match `M`), the out-of-range error and the truncating rounding.
|
||||
- **A cache miss and a stale hit moved only the Prometheus counter.**
|
||||
`metrics.RecordMiss` and `metrics.RecordStaleHit` bumped the collector and
|
||||
not the atomics that `Totals` reads, so the Cache page's "misses" and "stale
|
||||
hits" read 0 for the life of the process while the Grafana panel was right —
|
||||
exactly the disagreement `Record`'s doc comment says cannot happen.
|
||||
- **The uncached path never truncated.** `finalize` — the EDNS0/UDP-size step
|
||||
that turns an oversized answer into a question-only TC reply — was called
|
||||
only on the *cache hit* path. A multi-kilobyte answer from upstream, from the
|
||||
local zone or from a sinkhole left here as a datagram no 512-byte UDP client
|
||||
can receive, and the client silently dropped it. All four reply paths now
|
||||
finalize.
|
||||
- **The serve-stale prefetch gate was not single-flight.**
|
||||
`LRU.prefetchGate` was `make(chan struct{}, 64)`, so a burst of stale hits on
|
||||
one hot name launched up to 64 duplicate upstream exchanges instead of the
|
||||
one its own doc comment promised. It is a per-key latch now (a
|
||||
`map[Key]struct{}` under a mutex): distinct stale names still refresh in
|
||||
parallel, one name does not. `TryPrefetch`/`ReleasePrefetch` take the key.
|
||||
- **The upstream UDP client read buffer was hard-coded to 1232** while the
|
||||
engine advertises `dns.max_udp_size` upstream. An operator who raised
|
||||
`max_udp_size` above 1232 got a SERVFAIL for *every* answer over 1232 bytes:
|
||||
the datagram was read truncated and failed to unpack with "dns: overflowing
|
||||
header size". `resolver.NewPoolWithUDPSize` takes the advertised size and the
|
||||
app passes `cfg.DNS.MaxUDPSize`; `NewPool` keeps the 1232 default.
|
||||
- **Every query from a client that asks for EDNS0 NSID got SERVFAIL.**
|
||||
`dns.EDNS0_NSID.Nsid` is a *hex string* in miekg/dns v1.1.73 — `pack`
|
||||
hex-decodes it, `unpack` hex-encodes it — and the engine put the literal
|
||||
`"fancy-dns"` in it. `hex.DecodeString("fancy-dns")` fails, so the forwarded
|
||||
upstream query could not be built, the exchange failed, and the client got
|
||||
SERVFAIL. A one-line bug with total blast radius on a documented feature.
|
||||
Fixed with `hex.EncodeToString`, and pinned from both sides:
|
||||
`TestClientNSIDIsAnswered` (a client asking for NSID gets a real answer, the
|
||||
option we forward upstream decodes to our own name, and `dns.hide_identity`
|
||||
drops it) and `TestPinnedEDNS0NSIDIsHex` (the library contract, so a future
|
||||
bump that changes `Nsid` to raw bytes fails here with an explanation instead
|
||||
of silently breaking the feature).
|
||||
- **The login rate limiter was applied to every route except the one it exists
|
||||
for.** `allowRequest` is the only reader of `limitState.lockedTill` and it ran
|
||||
behind `authed`, while the login route was registered through `public`. So N
|
||||
bad API keys gave N × 401 and never a 429, and the lockout instead locked the
|
||||
caller out of the *rest* of the API — brute-force protection is the one thing
|
||||
the auth surface most needs, and it was the one thing that did not work. There
|
||||
is now a `limited()` wrapper (the limiter only, no auth) on the login route;
|
||||
`allowRequest` stays the single reader of the lockout state. Pinned by
|
||||
`TestLoginBruteForceEventuallyHitsTheRateLimiter`,
|
||||
`TestLoginLockoutExpiresWithTheClock`, `TestLoginLockoutIsPerIP`,
|
||||
`TestLoginLockoutDisabledWithoutFailures` and the `TestAuthedRouteRateLimit*`
|
||||
family, so both routes stay honest.
|
||||
- **A successful login reset the attacker's request budget.**
|
||||
`clearLoginFailures` deleted the whole per-IP `limitState`, which also reset
|
||||
the per-minute window, so one valid key in the middle of a brute-force burst
|
||||
handed back a fresh budget. It now clears only `failures` and `lockedTill`,
|
||||
and `TestSuccessfulLoginDoesNotResetTheRequestWindow` /
|
||||
`TestSuccessfulLoginClearsTheFailureCounter` pin both halves.
|
||||
- **DoH GET had no size cap.** The POST path is capped by `readCapped` at
|
||||
`dns.MaxMsgSize`; the GET path handed `?dns=` straight to a base64 decode and an
|
||||
`Unpack`, so the only limit was net/http's `MaxHeaderBytes` — roughly a
|
||||
megabyte of attacker-chosen base64 decoded per request, unauthenticated,
|
||||
against a 64 KiB cap on the neighbouring path. A DNS message cannot exceed
|
||||
65535 bytes, so the cap is `base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize)`
|
||||
and cannot refuse a conforming client. Pinned by
|
||||
`TestDoHGETQueryStringOverTheCapIsRefused` (413) and
|
||||
`TestDoHServesALargeButLegalQuery` (a message just under the wire limit is
|
||||
still served, so the cap cannot be tightened carelessly).
|
||||
- **A clean `Stop` sometimes returned "use of closed network connection".** The
|
||||
DoH listener has two owners: `Stop`'s close list, and `http.Server.Shutdown`,
|
||||
which tracks the same `net.Listener` because `serveDoH` hands it to
|
||||
`srv.Serve`. Whichever lost that race made an orderly shutdown look like a
|
||||
failed one. Closing an already-closed listener is not a failure, so `Stop` now
|
||||
ignores `net.ErrClosed` and only reports a real close error. It reproduced 3
|
||||
times in 3 runs with DoH enabled and never with it disabled, which is what
|
||||
made it findable; `TestStopIsIdempotent` is the pin.
|
||||
- **A per-transport `dropped` counter did not exist.** The DoH drop fix above
|
||||
was invisible in the UI: `HandlerStats.Dropped` is an aggregate that cannot
|
||||
say which transport dropped, and `TransportStats` had no `Dropped` field at
|
||||
all, even though all four transports bump the counter. Added, with the
|
||||
reasoning in the field's comment.
|
||||
- **A DoH drop was not counted as a drop.**
|
||||
`dropped` when `res.Drop`; the DoH path only reached `classify`, which maps
|
||||
`drop` to `errors`, so `HandlerStats.Dropped` under-counted DoH. One line, in
|
||||
the same position as the other two.
|
||||
- **Both shipped zone files matched nothing.** `blocklist.local_zones` and the
|
||||
`rpz`-format list source are read with the origin taken from the file's base
|
||||
name (`zonefile.OriginFromPath`: `local.example.zone` → `local.`), so the
|
||||
relative owner names in the examples were being qualified with that — a
|
||||
`*.doubleclick.net` rule blocked `*.doubleclick.net.local.`, and the local
|
||||
zone mixed relative owners with absolute ones. An operator copying either file
|
||||
got a server that answered nothing and blocked nothing. Both examples now
|
||||
spell every name out in full, and
|
||||
`TestExampleLocalZoneIsOriginIndependent` (zonefile) plus
|
||||
`TestExampleRPZZoneBlocksWhatItSays` (blocklist, through the real
|
||||
`parseRPZFiles` path) fail if they are "tidied up" back to relative names.
|
||||
- **The shipped configs were never loaded by anything.**
|
||||
`deploy/config.example.yaml` and the ConfigMap in `deploy/k8s/00-config.yaml`
|
||||
are now loaded through `config.Load` (which ends in `Validate`) and
|
||||
spot-checked, including that the k8s `${FANCYDNS_API_KEY}` still expands and
|
||||
still *fails* when the Secret is missing, and that the memory envelope matches
|
||||
the StatefulSet's 1Gi limit.
|
||||
- **The admin API's response shapes had drifted from the console's types** —
|
||||
verified against a running binary, not inferred. `blocklist.Source` has no
|
||||
`json` tags at all, so the Sources table rendered blank rows and its
|
||||
per-row toggle posted `name: undefined`; `acl.GroupSummary` is flat where the
|
||||
console expects a nested `match`, so every group card read "matches nothing";
|
||||
and **both write endpoints answered 400 to the payload the console actually
|
||||
sends** (`json: unknown field "ptr_suffixes"` on `PUT /api/clients`,
|
||||
`unknown field "health"` on `PUT /api/upstreams`). The Go side is being fixed
|
||||
with contract tests that assert the exact key set of every endpoint; the
|
||||
console side is the next piece of work.
|
||||
- **The GitHub Actions image job could never tag an image.** It used
|
||||
`${{ gitea.sha }}` in a workflow under `.github/`, and the `gitea` context
|
||||
only exists on Gitea; the image is also published by
|
||||
`.gitea/workflows/build.yaml`, which is the pipeline with registry access. It
|
||||
now tags with `github.sha` and only logs in and pushes when this repository
|
||||
actually has a registry token, so a GitHub runner builds and stops instead of
|
||||
failing.
|
||||
- **`api.constantTimeEqual` had no callers**, and the `authenticator` doc
|
||||
comment described a constant-time "API key scan" that does not exist. The real
|
||||
design is better than the comment (API keys are compared as SHA-256 digests
|
||||
through a hash-keyed map, so the plaintext is never stored) — the dead
|
||||
function is gone and the comment now describes the code.
|
||||
- **`maxGETResponse` was dead code** whose comment claimed a guarantee the
|
||||
handler did not provide. Deleted; the DoH response is written as packed.
|
||||
|
||||
### Fixed recently and worth not re-breaking
|
||||
## Pinned, not fixed (each one has a test that says so out loud)
|
||||
|
||||
- **Every relative RDATA name in a master file pointed at the DNS root.** The
|
||||
qualification pass ran *after* `dns.NewRR`, and the library resolves RDATA
|
||||
names against the root, so `www CNAME host` became `CNAME host.` — the alias
|
||||
pointed at the root, and so did every relative NS, MX, PTR, SRV, NAPTR and SOA
|
||||
target in every zone file. It is now a token-level pass driven by a table of
|
||||
which RDATA positions hold a name; dotted names stay absolute, so the RPZ
|
||||
targets `.` and `rpz-drop.` and the null MX/SRV targets are untouched.
|
||||
- **The cache snapshot never round-tripped.** `Snapshot` packed the whole key
|
||||
while `Load` decoded the record header out of the *key's* remainder instead of
|
||||
the value, so all 25 entries were skipped — every restart was a cold cache.
|
||||
There is now an explicit `encodeRecord`/`decodeRecord` pair with a version byte
|
||||
and a CRC-32C trailer. `Load` drops entries already past `ExpiresAt` and
|
||||
rewrites the TTL to the time actually left, so a served TTL never jumps back up
|
||||
across a restart.
|
||||
- **`${VAR}` expansion never worked inside quotes** — which is the only form
|
||||
anyone writes. The reference check sat in the same switch as quote tracking,
|
||||
below the `inDouble`/`inSingle` branches, so `api_keys: ["${KEY}"]` came out of
|
||||
`Load` unchanged and the admin API key was the literal string
|
||||
`"${FANCYDNS_API_KEY}"`. The k8s manifest looked correct and was not. A variable
|
||||
that is set but empty now expands to empty rather than erroring (the
|
||||
`${X}` / `${X-y}` / `${X:-y}` distinction); `\${FOO}` is literal.
|
||||
- **ACL PTR suffix matched across label boundaries**, so a group scoped to
|
||||
`example.com.` also swallowed `ample.com.` — a client could land in a group it
|
||||
does not belong to.
|
||||
- **The ACL `*` catch-all was dropped** from the rendered domain patterns, so the
|
||||
engine's per-query recompile lost the "everything" case.
|
||||
- **`Upstream.Success` never closed an open breaker.** A recovered upstream stayed
|
||||
excluded for the full cooldown — the mirror of the older bug where a failed
|
||||
exchange marked a dead upstream healthy.
|
||||
- **`Exchange` fanned out in parallel even under `policy: failover`**, because the
|
||||
parallel path was only taken when exactly one upstream was chosen.
|
||||
- **The cache byte budget was never fed to the LRU.** `maxBytes` was enforced per
|
||||
shard on insert but had no setter and nothing called one, so `cache.size` had no
|
||||
predictable memory cost.
|
||||
- **The memory envelope** (`memory.limit/reserve/cache_max_bytes`) is new, wired to
|
||||
`debug.SetMemoryLimit`. The reserve covers what the Go heap does not account
|
||||
for: goroutine stacks, runtime metadata, and the bbolt mmap.
|
||||
- **Blocklist:** leading BOM stripped, `!allow` works, `SourceMeta` exists for a
|
||||
rule-only source, `Pattern()` reports the bare name (`WireName()` gives the
|
||||
wildcard form for the API), RPZ inherits the zone default TTL, hosts files
|
||||
complain about a missing address, adblock handles CRLF and over-long lines.
|
||||
- **`$TTL` was ignored** by the zone parser; every record got the SOA minimum.
|
||||
- **`TestRebindUnderConcurrentQueries` was a flake** (2 runs in 10) — it asserted
|
||||
a reader had to observe two specific addresses, which is a claim about what a
|
||||
goroutine happened to see. It now pins the copy-on-write invariant itself:
|
||||
every observed snapshot must equal one of the published lists.
|
||||
- **`TestHolderSwapIsAtomic` had the wrong invariant**, not the Holder.
|
||||
- **CI could not have passed:** `go.mod` requires Go 1.27.1, CI pinned 1.23 and
|
||||
the Dockerfile pinned `golang:1.23-alpine`.
|
||||
- **The Dockerfile clobbered the console** — it copied the source tree after the
|
||||
console bundle.
|
||||
- **The k8s set could not have worked:** `hostNetwork: true` with `replicas: 2`
|
||||
and no SO_REUSEPORT. Now a LoadBalancer Service, a separate ClusterIP Service
|
||||
for the Ingress, a ServiceAccount, an API-key Secret, per-pod PVCs, a
|
||||
kustomization. The HPA is gone.
|
||||
- **`serveUDP` called `net.ResolveUDPAddr` per packet**; the peer already is a
|
||||
`*net.UDPAddr`.
|
||||
The standing decision for this round was: **pin, do not change**. Every item
|
||||
below is behaviour someone may want to change deliberately; each has a test
|
||||
that fails when it changes, and says what to do when it does.
|
||||
|
||||
## New findings from the `api` suite, unfixed
|
||||
|
||||
- **The login rate limiter never applies to the login endpoint.** `handleLogin`
|
||||
is registered through `public`, which bypasses `authed` and therefore
|
||||
`allowRequest` — the only reader of `lockedTill`, and `handleLogin` the only
|
||||
writer of `failures`. So `api.rate_limit.per_minute`, documented as "requests
|
||||
per minute per IP **for the login endpoint**", applies to every *other* route
|
||||
instead: posting 100 bad keys to `/api/auth/login` returns 100 × 401 and never
|
||||
a 429, and the lockout only ever locks the caller out of the rest of the API.
|
||||
Brute-force protection is the one thing the auth surface most needs. The suite
|
||||
asserts nothing about this, so the fix is a free choice: register login
|
||||
through the limiter, or move the lockout check into the login handler.
|
||||
- **Unknown config keys are silently ignored** (a settled decision, see the
|
||||
table above). `TestLoadIgnoresUnknownKeys` in `internal/config` also pins the
|
||||
asymmetry that makes this surprising: a typo is invisible, while a known key
|
||||
with a bad *type* is a startup failure.
|
||||
- **`metrics.OutcomeLocal` has no producer.** The engine reports a local-zone
|
||||
answer as `qlog.ActionAllow`, which `outcomeFor` maps to `OutcomeUpstream`, so
|
||||
every locally answered query is counted as an upstream one and the
|
||||
`outcome="local"` series is never published — the dashboard's local column is
|
||||
permanently empty. `TestPinnedLocalAnswersAreCountedAsUpstream` asserts the
|
||||
upstream counter moves by one and the local series stays absent. Fixing it
|
||||
moves published counters, so it wants a decision and a dashboard note.
|
||||
- **An RPZ record does not cover the name it names.** `rpzRule` gives a bare
|
||||
owner name the subdomain form (`*.name`), and a subdomain-form rule matches
|
||||
the name's children but not the name itself, so `analytics.example.org.` in a
|
||||
policy zone blocks `cdn.analytics.example.org.` and leaves
|
||||
`analytics.example.org.` resolving normally. RPZ semantics say the record
|
||||
applies to the domain and everything under it, so `**.name` is what belongs
|
||||
here — a one-character change in `rpzRule` that alters which queries get
|
||||
blocked. Verified end to end against a running binary: the subdomain is
|
||||
NXDOMAIN, the apex is not. Pinned by `TestExampleRPZZoneApexIsNotCovered`,
|
||||
which also shows the same gap on the allow side (an `rpz-passthru.` record
|
||||
for a domain does not allowlist the domain itself).
|
||||
- **Auth configuration is frozen at `New`.** `api.base_path`,
|
||||
`api.trusted_proxies` and the authenticator (`auth.mode`, `api_keys`) are
|
||||
captured once in `New`; a reload re-reads CORS only. Writing a new API key
|
||||
through the settings endpoint therefore answers `ok` while the running server
|
||||
keeps the old key, and a `trusted_proxies` change needs a restart. A config
|
||||
write endpoint that cannot apply its own auth change is an operational trap.
|
||||
Pin the current behaviour with a test before changing it.
|
||||
- **`api.constantTimeEqual` has no callers**, and the `authenticator` doc comment
|
||||
describes a constant-time "API key scan" that does not exist. The real design
|
||||
is better than the comment: API keys are compared as SHA-256 digests through a
|
||||
hash-keyed map and the key itself is never stored, which satisfies the
|
||||
AGENTS.md invariant. The comment is wrong, not the code; delete the dead
|
||||
function and fix the comment.
|
||||
|
||||
### Open decisions
|
||||
|
||||
- **Unknown config keys are silently ignored.** Writing `block:` where the struct
|
||||
wants `blocklist:` produced a plausible-looking server, and two test fixtures
|
||||
were caught by this rather than by what they were testing. `KnownFields(true)`
|
||||
would reject it, but that is a behaviour change for anyone carrying extra keys.
|
||||
captured once; a reload re-reads CORS only. Writing a new API key through
|
||||
`PUT /api/settings` answers `ok` while the running server keeps the old key.
|
||||
`TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart` and
|
||||
`TestCORSOriginsAreReadAtHandlerBuild` hold this in place until someone decides
|
||||
whether a reload should re-apply it.
|
||||
- **DoH clients behind an Ingress all present as the proxy.** `httpRemoteAddr`
|
||||
uses the raw `RemoteAddr` and never consults `trusted_proxies`, so every DoH
|
||||
client lands in the Ingress's ACL group. Fail-closed if that group is
|
||||
restrictive, fail-open if it is permissive. Pin the behaviour with a test
|
||||
before changing it.
|
||||
- **A character-string longer than 255 bytes cannot be packed by this version
|
||||
of `miekg/dns` (v1.1.73).** `TXT.len()` reports a >255-byte string as zero
|
||||
length and `EDNS0_NSID.len()` is likewise wrong, so `Msg.Pack` fails with
|
||||
"dns: overflow packing opt" — or, on the server write path, silently fails
|
||||
and the query times out. Two consequences: a fixture that wants a
|
||||
multi-kilobyte answer has to build many short strings (the engine harness
|
||||
uses 15 x 200), and a **real DoT/DoH/UDP client that sends an NSID option
|
||||
gets a SERVFAIL**, because the engine forwards its own NSID for such a query
|
||||
and the message will not pack. Not fixed: it needs a library bump or a
|
||||
deliberate decision to answer NSID locally rather than forward it.
|
||||
- **`"1Gi"` is not a valid size** — only `GiB`, `G`, `GB`. The parser is
|
||||
deliberately strict, but k8s spells it `1Gi` and the k8s manifests are the
|
||||
likeliest place an operator writes a memory budget. Left as is and pinned by
|
||||
a test; worth a decision.
|
||||
- **`metrics.OutcomeLocal` has no producer.** The engine reports a local-zone
|
||||
answer as `ActionAllow`, which `finish` maps to the *upstream* outcome, so
|
||||
locally answered queries inflate the upstream counter and the dashboard's
|
||||
local column is always empty. Not changed: fixing it moves published
|
||||
counters, so it wants a decision and a changelog note.
|
||||
client lands in the Ingress's ACL group: fail-closed if that group is
|
||||
restrictive, fail-open if it is permissive.
|
||||
`TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader` pins it, with the
|
||||
reason honouring `X-Forwarded-For` without `trusted_proxies` would break the
|
||||
ACL and rate-limit invariants.
|
||||
- **`api.cors_origins: ["*"]` echoes the request's `Origin` and sets
|
||||
`Access-Control-Allow-Credentials: true`.** Deliberate (a literal `*` is
|
||||
ignored by browsers when credentials are involved), but it does mean any
|
||||
website can drive the admin API with whatever the browser has cached for that
|
||||
host. The preflight also answers 204 for any origin and any path.
|
||||
`TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight` pin all of it.
|
||||
- **A partial bind failure is invisible.** `serveDoT`/`serveDoH` record a failed
|
||||
bind in an error list only, so `ListenerInfo.Err` is never set and the admin
|
||||
API cannot show which address failed.
|
||||
`TestStartReportsAPartialFailureButServes` pins that `Start` returns nil
|
||||
whenever any listener came up.
|
||||
- **A question-less message is answered on UDP and kills the connection on
|
||||
TCP/DoT.** `TestUDPQuestionlessMessageReachesTheEngine` pins the UDP half (the
|
||||
engine turns it into a FORMERR); the stream path returns instead. Probably
|
||||
right for both, but it is an asymmetry worth knowing about.
|
||||
- **A UDP query in flight at `Stop` can be lost.** The stream paths register
|
||||
with `s.wait`, so `Stop` waits for them; `servePacket` does not, so a UDP
|
||||
query already inside the pipeline when the listener closes may never be
|
||||
answered. That is arguable — UDP has no connection to hang up on and the
|
||||
client will retry — but the asymmetry with TCP/DoT is not pinned anywhere, and
|
||||
it should be a decision rather than an accident.
|
||||
- **`doh.sent` is never bumped.** The DoH handler writes its answer through
|
||||
`net/http`, so the `sent` counter is only meaningful on UDP/TCP/DoT, and the
|
||||
console's transport table shows `sent: 0` for DoH. Pinned as current
|
||||
behaviour by `TestCountersFollowTheEngineOutcome` with a comment saying why.
|
||||
- **`api.metrics.path` is ignored**
|
||||
always `${base_path}/metrics`); `POST /api/blocklists/refresh` returned
|
||||
`{"names": null}`; an empty `config_yaml` in the settings form is not valid (it
|
||||
decodes onto `Default()`, which enables a blocklist with no sources); `"1Gi"`
|
||||
is not a valid size — only `GiB`, `G`, `GB` — even though that is how k8s
|
||||
spells it.
|
||||
|
||||
## Continuation plan, in order
|
||||
|
||||
1. ~~**`internal/engine` suite**~~ — **done**, 23 tests. The original ask was
|
||||
the largest uncovered surface and the one every
|
||||
packet touches. Pin the pipeline order end to end: ACL deny before anything
|
||||
else, local zone before blocklist, blocklist before cache, a blocklist hit
|
||||
served and cached without touching the upstream, the concurrency and
|
||||
rate-limit refusals, serve-stale with its single background refresh,
|
||||
truncation against a 512-byte client buffer, and that the metrics and qlog
|
||||
hooks fire exactly once per query and not at all for a query rejected before
|
||||
the pipeline reached them. Reuse the harness in
|
||||
`internal/app/integration_test.go`.
|
||||
2. ~~**`internal/api` suite**~~ — **done, partly** (78 cases): the route table,
|
||||
the three auth modes, the hashed-key invariant, `trusted_proxies` and
|
||||
loopback trust, read-only keys, `allow_config_write: false`, panic recovery.
|
||||
**Not covered**, and the harness already carries what they need
|
||||
(`fake.stream`/`unsub` for SSE, `fake.fail` for error paths, `harness.srv.now`
|
||||
as an advanceable clock): CORS, the rate limiter, the SSE
|
||||
open/unsubscribe/no-leak case, `/metrics` (enabled/protected/501), and the
|
||||
settings PUT/validate/reload happy paths.
|
||||
3. **`internal/server` suite** — in progress. All four listeners behind one handler,
|
||||
per-listener config, the UDP read-deadline reset, shutdown ordering.
|
||||
4. **`internal/metrics` + `internal/cluster`** — the collectors are **done** (9
|
||||
tests, the metric names pinned as the contract with the dashboards); what is
|
||||
left is `cluster`: the rolling rings, the Valkey lease, pub/sub sync,
|
||||
divergence detection. `miniredis` is the obvious test server.
|
||||
5. ~~Tests for `config.Size`~~ — **done**, and the unit-multiplier bug it was
|
||||
supposed to catch was real.
|
||||
6. ~~Wire `memory:` into `deploy/k8s/00-config.yaml`, matched to the
|
||||
StatefulSet's 1Gi limit.~~ — **done**: `1GiB` limit, `256MiB` reserve,
|
||||
`384MiB` cache budget.
|
||||
7. Decide on `KnownFields(true)`.
|
||||
8. Re-run the console end to end; the API surface has changed several times.
|
||||
1. **`internal/cluster` suite** — in progress. The rolling heartbeat, the Valkey
|
||||
lease and leader handoff, pub/sub config sync, divergence detection and
|
||||
reconnect, against `miniredis`.
|
||||
2. **The API response contract** — in progress on the Go side
|
||||
(`internal/app` contract tests + the fixes listed above).
|
||||
3. **The console side of that contract** — the next piece of work, and it should
|
||||
be done against the canonical key list from step 2: the `qtype` fields that
|
||||
are numbers on the wire but strings in TypeScript (the server also sends
|
||||
`qtype_name`), `blocked_pct` arriving as 0..100 from the dashboard and 0..1
|
||||
over SSE, the `whoami.can_write` / `read_only` flags that are fetched and
|
||||
never used (a read-only key still shows every Save control), a `latency`
|
||||
block the dashboard never receives, the cache "Lookups"/"Last seen" columns
|
||||
reading `count`/`last_seen` where the API sends `hits`, and the refresh toast
|
||||
that says "0 sources queued" because the API returned null.
|
||||
4. **The remaining server tests** — in progress: shutdown ordering (in-flight
|
||||
queries, idempotent `Stop`, a cancelled context, the port actually being
|
||||
released), the stats counters, and a table for the pure helpers.
|
||||
5. **Decide the open questions** in the pinned list above, one at a time, each
|
||||
with a changelog note: the RPZ apex, `OutcomeLocal`, the frozen auth config,
|
||||
the DoH client identity, CORS wildcards.
|
||||
6. **Stop hand-writing the console's types.** The contract tests make the Go
|
||||
side authoritative; the cheapest durable answer is generating
|
||||
`web/src/lib/types.ts` from the Go structs (or an OpenAPI document emitted by
|
||||
the server) so `apiFetch` is typed against something real instead of
|
||||
`JSON.parse(text) as T`.
|
||||
|
||||
## Design notes worth preserving
|
||||
|
||||
@@ -292,10 +329,11 @@ panic recovery, the base path, all three auth modes, read-only keys and
|
||||
and work against a value that cannot change under them. Prefer a new immutable
|
||||
snapshot over a lock.
|
||||
- **Cache invalidation on rule changes flushes the cache** rather than guessing
|
||||
which subtrees are affected. `invalidateBlocklistCache()` in
|
||||
`internal/app/hooks.go`.
|
||||
which subtrees are affected (`invalidateBlocklistCache` in
|
||||
`internal/app/hooks.go`).
|
||||
- **API responses share the field names in `internal/app/hooks.go`.** If you
|
||||
rename one, grep the frontend too.
|
||||
rename one, grep the frontend too — and now also expect `internal/app`'s
|
||||
contract test to fail, which is the point of it.
|
||||
- **API keys are compared by SHA-256 hash, constant-time**, and
|
||||
`X-Forwarded-For` is honoured *only* from `trusted_proxies`.
|
||||
- **The UDP listener resets its read deadline every iteration.** A deadline set
|
||||
@@ -307,9 +345,13 @@ panic recovery, the base path, all three auth modes, read-only keys and
|
||||
a truncated answer is never cached (it would be served truncated forever).
|
||||
- **`PatternKind.Pattern` is the display form; `WireName` is the wildcard form.**
|
||||
The internal `*.` / `**.` prefixes must not leak into the UI, but the API
|
||||
needs them to re-add a rule unchanged.
|
||||
needs them to re-add a rule unchanged (the domains list ships `rule` for
|
||||
exactly this).
|
||||
- **pprof is loopback-only by construction**, not by policy, so no config flag
|
||||
can turn it into a network-reachable unauthenticated endpoint.
|
||||
- **A master file's origin is its base name**, so a relative owner name in a
|
||||
zone is qualified with the file's name, not with the zone you meant. Absolute
|
||||
names are the only spelling that survives a rename.
|
||||
|
||||
## Test-hygiene traps
|
||||
|
||||
@@ -318,14 +360,76 @@ panic recovery, the base path, all three auth modes, read-only keys and
|
||||
- A test that closes a stop channel *after* `wg.Wait()` deadlocks the whole
|
||||
package and looks like "the suite hangs". Close first, then wait.
|
||||
- Assert on invariants, not on what a concurrent goroutine happened to observe.
|
||||
"A reader must have seen address X" is a flake; "every list a reader sees is one
|
||||
of the published ones" is a real property and cannot flake.
|
||||
"A reader must have seen address X" is a flake; "every list a reader sees is
|
||||
one of the published ones" is a real property and cannot flake.
|
||||
- `tsc -b` replays **cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`.
|
||||
After a large edit, `rm node_modules/.tmp/*.tsbuildinfo`.
|
||||
- `vite build` empties `outDir` and **deletes `internal/web/dist/.gitkeep`**.
|
||||
Restore it or the directory disappears from git.
|
||||
- **`dns.Msg` with a character-string over 255 bytes does not pack** in
|
||||
miekg/dns v1.1.73 (`TXT` of 4 KiB, any `EDNS0_NSID`). The failure is silent on
|
||||
a server write path, so it presents as a client-side timeout; build a big
|
||||
answer out of many strings under 255 bytes instead.
|
||||
Restore it or the directory disappears from git. `//go:embed all:dist` still
|
||||
builds while the directory is empty, so a console rebuild in the middle of
|
||||
someone else's `go build` is not fatal.
|
||||
- **A `TXT` string over 255 bytes does not pack** in miekg/dns v1.1.73 — the
|
||||
error is `dns: string exceeded 255 bytes in txt`, and 255 is the exact
|
||||
boundary. On a fake upstream's write path the failure is silent and presents
|
||||
as a client timeout, so build a multi-kilobyte answer out of many short
|
||||
strings and assert the fake's write error before blaming the client.
|
||||
- **`dns.EDNS0_NSID.Nsid` is a hex string, not the bytes on the wire.** A plain
|
||||
value such as `"fancy-dns"` fails with `dns: overflow packing opt`, and a long
|
||||
NSID packs fine (unlike `TXT`). Always hex-encode on the way in.
|
||||
- `gofmt -l internal/` is the fastest way to see that a file does not parse.
|
||||
- The console has **no test runner at all** — only `tsc -b --noEmit` and
|
||||
`oxlint`. A field-name drift between the API and the console is invisible to
|
||||
both, which is why the Go side now has contract tests.
|
||||
|
||||
## Fixed earlier and worth not re-breaking
|
||||
|
||||
A running list, kept because each of these was invisible until it was found:
|
||||
|
||||
- `config.ParseSize` ignored the unit multiplier entirely (`512MiB` parsed as
|
||||
512 bytes), so every memory budget in the tree was off by a factor of the
|
||||
unit. `size_test.go` pins the units, the shadowing cases, the range and the
|
||||
truncating rounding.
|
||||
- A cache miss and a stale hit moved only the Prometheus counter and not the
|
||||
atomics the console's `Totals` reads, so the Cache page read zero for the
|
||||
life of the process.
|
||||
- `finalize` — the EDNS0/UDP-size step that turns an oversized answer into a
|
||||
question-only TC reply — ran only on the cache-hit path, so a multi-kilobyte
|
||||
answer from upstream, from the local zone or from a sinkhole was left as a
|
||||
datagram a 512-byte UDP client silently dropped.
|
||||
- The serve-stale prefetch gate was a 64-slot channel, so a burst of stale hits
|
||||
on one hot name launched up to 64 duplicate upstream exchanges instead of the
|
||||
one its own comment promised. It is a per-key latch now.
|
||||
- The upstream UDP client read buffer was hard-coded to 1232 while the engine
|
||||
advertises `dns.max_udp_size`, so an operator who raised it got SERVFAIL for
|
||||
every answer over 1232 bytes.
|
||||
- Every relative RDATA name in a master file pointed at the DNS root: the
|
||||
qualification pass ran *after* `dns.NewRR`, so `www CNAME host` became
|
||||
`CNAME host.`. It is now a token-level pass driven by a table of which RDATA
|
||||
positions hold a name.
|
||||
- The cache snapshot never round-tripped: `Load` decoded the record header out of
|
||||
the *key's* remainder instead of the value, so every restart was a cold cache.
|
||||
There is now an `encodeRecord`/`decodeRecord` pair with a version byte and a
|
||||
CRC-32C trailer, and `Load` drops expired entries and rewrites the TTL to the
|
||||
time actually left.
|
||||
- `${VAR}` expansion never worked inside quotes — the only form anyone writes —
|
||||
because the reference check sat below the quote branches in the same switch.
|
||||
The admin API key was the literal string `"${FANCYDNS_API_KEY}"`.
|
||||
- ACL PTR suffix matching crossed label boundaries, so a group scoped to
|
||||
`example.com.` also swallowed `ample.com.`; the ACL `*` catch-all was dropped
|
||||
from the rendered domain patterns; `Upstream.Success` never closed an open
|
||||
breaker; `Exchange` fanned out in parallel even under `policy: failover`; the
|
||||
cache byte budget was never fed to the LRU.
|
||||
- **`TestRebindUnderConcurrentQueries` was a flake** (2 runs in 10) because it
|
||||
asserted a reader had to observe two specific addresses. It now pins the
|
||||
copy-on-write invariant itself.
|
||||
- CI could not have passed (`go.mod` requires Go 1.27.1, CI and the Dockerfile
|
||||
pinned 1.23), the Dockerfile clobbered the console by copying the source tree
|
||||
after the bundle, and the k8s set could not have worked (`hostNetwork: true`
|
||||
with `replicas: 2` and no SO_REUSEPORT).
|
||||
- `$TTL` was ignored by the zone parser; every record got the SOA minimum.
|
||||
- Blocklist: leading BOM stripped, `!allow` works, `SourceMeta` exists for a
|
||||
rule-only source, `Pattern()` reports the bare name while `WireName()` gives
|
||||
the wildcard form, RPZ inherits the zone default TTL, hosts files complain
|
||||
about a missing address, adblock handles CRLF and over-long lines.
|
||||
- `serveUDP` called `net.ResolveUDPAddr` per packet; the peer already is a
|
||||
`*net.UDPAddr`.
|
||||
Reference in new issue
Block a user