Files
fancy-dns/STATUS.md
T
developer cc7466823b
build / build-push (push) Failing after 19s
Wire the memory envelope into the k8s ConfigMap; bring STATUS.md up to date
The container limit is 1Gi, so declare the envelope next to it: a 1GiB soft
heap limit, a 256MiB reserve for what the Go heap does not account for, and an
explicit 384MiB cache byte budget. cache.size is an entry count, so the byte
budget is what actually bounds the cache now that ParseSize applies its units.

STATUS.md records the round: the fixes, the new suites (engine, config.Size,
metrics, and the api suite that landed separately), three findings left
unfixed -- the login rate limiter never applies to the login endpoint, auth
configuration is frozen at New, and api.constantTimeEqual is dead -- and the
continuation plan with items 1, 2, 5 and 6 struck.
2026-09-27 16:03:25 +00:00

20 KiB
Raw Blame History

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.

Goal

A lightweight, high-performance recursive DNS server with:

  • Go backend — sharded LRU cache backed by bbolt, blocklist replication from multiple formats, per-client ACLs, query logging, Prometheus metrics.
  • Secure DNS — DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484). DNSSEC validation was explicitly scoped out; AD/CD/extended-RCODE are passed through only.
  • React + Tailwind admin console embedded in the binary.
  • Config as zone files + YAML, hot-reloadable, with Valkey/Redis for cross-instance sync (chosen over AMQP).

Architecture decisions (settled with the user)

Decision Choice Why
Scope Full production clustering, metrics, ACLs, query logs, hot reload, Docker, k8s, CI
Config store YAML + DNS master files reviewable/diffable in git
Cluster sync Valkey (Redis protocol) simple, mature Go client, easy local dev
Secure DNS DoT + DoH, no DNSSEC validation avoids signer/anchor machinery; pass-through only
Hot cache Sharded LRU in memory, bbolt for snapshots no I/O on the hot path; warm restart
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

Package layout

cmd/fancy-dns/main.go          entrypoint: flags, signals, SIGHUP reload
internal/
  config/       Config struct, Duration, Size, Validate, atomic Store + hooks,
                ${VAR} environment expansion in Load
  zonefile/     RFC 1035 master-file parser ($TTL/$ORIGIN, parens, serial math)
  blocklist/    Set (exact/sub/root maps), 4 parsers, Manager (fetch+compile+swap)
  cache/        Key, Entry, sharded LRU, TTL policy, serve-stale, bbolt Store
  resolver/     Upstream pool: health, circuit breakers, parallel/failover/random
  acl/          compiled groups, CIDR/PTR/tag matching, per-group domain policy
  localzone/    authoritative data from master files, wildcards, delegations
  engine/       THE QUERY PIPELINE (see below), reverse-PTR cache
  server/       UDP, TCP, DoT, DoH listeners; all funnel into one handler
  qlog/         bbolt query-log ring + live in-memory ring + SSE fanout
  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
  web/          go:embed of the built console
web/            Vite + React + TS + Tailwind console
deploy/         example config, example zones, k8s manifests

Query pipeline order (engine.Handle)

validate → ACL deny → per-client concurrency → global concurrency → rate limit
  → group allow/deny domains → local zone → blocklist → cache → upstream
  → cache put → finalize (EDNS0, UDP truncation) → metrics + qlog

Cache is a single shard-local lookup; only a miss touches the network.

Status

Green — the whole tree passes

gofmt clean, go build ./... clean, go vet clean.

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.

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.

No test suite yet

cluster, server, web.

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.

Fixed in the last round (the engine and config.Size work)

  • 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.

Fixed recently and worth not re-breaking

  • 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.

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.
  • 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.
  • 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.

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.

Design notes worth preserving

  • Cache stores packed wire bytes, not *dns.Msg. A hit costs one Unpack and yields a private copy, so serving never mutates shared state and TTL ageing is free. Same trade dnsmasq makes.
  • Everything the query path reads is behind an atomic.Pointer holder — ACL, blocklist, localzone, and now the upstream pool. Readers load the pointer once 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.
  • API responses share the field names in internal/app/hooks.go. If you rename one, grep the frontend too.
  • 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 once expires for good and turns the loop into a spin that never reads another packet (caught by TestBlocklistToggle).
  • Only the Valkey lease holder fetches lists, so N instances do not hit a mirror N times.
  • Serve-stale answers get TTL 1; an expired upstream answer is never cached; 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.
  • pprof is loopback-only by construction, not by policy, so no config flag can turn it into a network-reachable unauthenticated endpoint.

Test-hygiene traps

  • dns.NewRR needs a TTL column: dns.NewRR("host.example.com") does not parse. That is a fixture bug, not a product bug.
  • 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.
  • 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.
  • gofmt -l internal/ is the fastest way to see that a file does not parse.