Records the current state rather than the previous plan. The important change
is in the localzone section: the relative-CNAME defect is narrowed to how
Store re-serialises records, not the parser — qualifyRdata and relative are
both correct and assemble only touches the owner, so the next person should
look at localzone rather than "fixing" the parser.
Also adds make check-go to the dev loop, the memory envelope, the env-expansion
fix (the admin API key was the literal string "${FANCYDNS_API_KEY}" behind a
green build), the two open decisions, and a reworked plan starting with the one
red package.
Adds to the hygiene notes: subagent briefs must require that whatever is on disk
when the budget runs out compiles and passes, because an agent cannot know it is
out of budget. Three of five left a red tree.
236 lines
13 KiB
Markdown
236 lines
13 KiB
Markdown
# fancy-dns — development status and continuation plan
|
|
|
|
Last updated after the env-expansion fix, the memory envelope and `make
|
|
check-go`. Everything below reflects the tree at `4c00bb4`.
|
|
|
|
## 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 |
|
|
|
|
## 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
|
|
|
|
`gofmt` clean, `go build ./...` clean, `go vet` clean.
|
|
|
|
`go test -race ./internal/... -count=1` is green for **acl, app, blocklist, cache,
|
|
config, qlog, resolver, zonefile**.
|
|
|
|
`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`.
|
|
|
|
### Red — one package
|
|
|
|
**`internal/localzone`**, 5 failures. One is a real production bug:
|
|
|
|
- **A relative CNAME target is emitted unqualified.** A master file line
|
|
`www CNAME host` produces `www.example.com. CNAME host.` instead of
|
|
`host.example.com.` — the alias points at the DNS root. Per RFC 1035 §5.1 the
|
|
target is relative to the zone origin.
|
|
|
|
Narrowed, not yet confirmed: `zonefile.assemble` only calls `relative()` on the
|
|
*owner* and passes RDATA tokens through untouched, and `qualifyRdata(rr,
|
|
z.Origin)` is invoked with the origin that the owner path used successfully.
|
|
So the parser looks correct and the defect is more likely in how
|
|
`internal/localzone` stores or re-serialises records. **Look there first**;
|
|
do not "fix" `qualifyRdata`, it is right.
|
|
|
|
- `TestCNAMELoopIsNotFollowed` — bad fixture, not a product bug:
|
|
`dns.NewRR("alias.example.com")` has no TTL column so it will not parse.
|
|
- `TestLoadOriginDerivation` — a file whose first record has no owner name fails
|
|
with `record has no owner name and no previous owner to inherit`; decide
|
|
whether the parser should accept it when the origin is known.
|
|
- `TestLoadMergesFilesForOneOrigin` — `Count() = 3, want 4`. A record is still
|
|
dropped when two files feed one origin. Same class as the bug reproduced by
|
|
hand: a record whose first token is consumed as RDATA of the previous one.
|
|
|
|
### No test suite yet
|
|
|
|
`server`, `api`, `metrics`, `cluster`. (`app` has integration tests that pass;
|
|
`engine` and `qlog` now have suites.)
|
|
|
|
### Fixed earlier and worth not re-breaking
|
|
|
|
- **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 set 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.
|
|
- **`$TTL` was ignored** by the zone parser; every record got the SOA minimum.
|
|
- **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.
|
|
- **`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`.**
|
|
- **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. The
|
|
reserve exists because the Go heap does not account for goroutine stacks,
|
|
runtime metadata, or the bbolt mmap for the cache snapshot and the query log.
|
|
- **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.
|
|
- **`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 — it cannot drive a StatefulSet.
|
|
- **`serveUDP` called `net.ResolveUDPAddr` per packet**; the peer already is a
|
|
`*net.UDPAddr`.
|
|
- **`api.pprof`** mounts `net/http/pprof`, refused unless every admin address is
|
|
loopback — the handlers sit on the app-level mux and would bypass the API's own
|
|
auth.
|
|
|
|
### Open decisions
|
|
|
|
- **Unknown config keys are silently ignored.** Writing `block:` where the struct
|
|
wants `blocklist:` produced a plausible-looking server. `KnownFields(true)`
|
|
would reject it, but that is a behaviour change for anyone carrying extra
|
|
keys. Two test fixtures were caught by this, not by the thing they tested.
|
|
- **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.
|
|
|
|
## Continuation plan, in order
|
|
|
|
1. **Unblock `internal/localzone`.** Start with how the Store re-serialises
|
|
records; the parser side looks correct. Then the fixture, the origin
|
|
derivation and the merge count.
|
|
2. `internal/server` suite: all four listeners behind one handler, per-listener
|
|
config, the UDP read-deadline reset, shutdown ordering.
|
|
3. `internal/api` suite: every route, the three auth modes, constant-time API-key
|
|
comparison, `X-Forwarded-For` honoured *only* from `trusted_proxies`, CORS,
|
|
rate limiting, and the SSE stream (open a subscriber, assert delivery,
|
|
unsubscribe, assert no goroutine leak).
|
|
4. `internal/metrics` + `internal/cluster`: the collectors, the rolling rings,
|
|
the Valkey lease, pub/sub sync, divergence detection. `miniredis` is the
|
|
obvious test server.
|
|
5. Tests for `config.Size` — the newest parsing code in the repo and the most
|
|
likely to be wrong at a boundary like `1.5GiB`.
|
|
6. Wire `memory:` into `deploy/k8s/00-config.yaml`, matched to the StatefulSet's
|
|
1Gi limit.
|
|
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 profiler flag
|
|
can turn it into a network-reachable unauthenticated endpoint.
|
|
|
|
## Test-hygiene traps
|
|
|
|
- **Subagent briefs must require "whatever is on disk when you run out of
|
|
budget must compile and pass".** Three of five agents left a red tree; the
|
|
check has to be in the brief, because an agent cannot know it is out of budget.
|
|
- `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.
|
|
- A test that closes a stop channel *after* `wg.Wait()` deadlocks the whole
|
|
package and looks like "the suite hangs". Close first, then wait.
|
|
- `gofmt -l internal/` is the fastest way to see that an agent left a file that
|
|
does not parse.
|
|
- `dns.NewRR` needs a TTL column. `dns.NewRR("host.example.com")` does not
|
|
parse; that is a fixture bug, not a product bug.
|