Records the four bugs the first end-to-end run surfaced, what is green, what is still without a suite, and what is in flight, so the next session does not start from the previous plan.
12 KiB
12 KiB
fancy-dns — development status and continuation plan
Last updated after the bbolt/blocklist/acl/resolver fix session and the first end-to-end run of the console. Everything below reflects the working tree.
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 |
Package layout
cmd/fancy-dns/main.go entrypoint: flags, signals, SIGHUP reload
internal/
config/ Config struct, Duration type, 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
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
Done and verified
gofmt -l ./cmd ./internalempty,go build ./...clean,go vet ./...clean.go test -race ./internal/... -count=1is green foracl,app,blocklist,cache,config,resolver,zonefile.- Test suites exist and are green for:
acl(26 tests + 4 benchmarks),blocklist,cache(incl. benchmarks),config,resolver,zonefile. internal/localzone,internal/engineandinternal/qlogare being written right now — see "in flight" below.README.mdis being written from the source right now.
The four bugs from the first end-to-end run
Found by running the binary and querying it, not by a test. All four are being
fixed in internal/zonefile + internal/localzone:
$TTL 60is ignored. Every record came back with the SOA minimum (3600).Zone.DefaultTTLis documented as the$TTLvalue; RFC 1035 §5.2 says an explicit$TTLwins.- Records are swallowed when the previous record has an odd token count.
ns1 A 192.0.2.53followed bytxt TXT "hello world"parses as one recordns1 TXT "TXT" "hello world"— the TXT record is lost entirely. Data loss inassemble/Parse. - A delegated name answers NXDOMAIN instead of a referral. With
sub NS ns1.sub.example.test.,host.sub.example.test. AreturnedNXDOMAIN aa=true; it must be NOERROR, AA false, NS in Authority. - A wildcard answer keeps the literal wildcard owner. A query for
x.wild.example.test. Areturned an answer owned by*.wild.example.test.. RFC 4592 §3.3.1 requires the owner name to be replaced with the query name.
Fixed earlier and worth not re-breaking
cache.Snapshot/Loadnever round-tripped.Snapshotpacked the whole key whileLoaddecoded 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 explicitencodeRecord/decodeRecordpair with a version byte and a CRC-32C trailer, so a future format change is detected rather than silently misparsed.Loadalso drops entries already pastExpiresAtand rewrites the TTL to the time actually left, so a served TTL never jumps back up across a restart.- ACL PTR suffix matched across label boundaries. A group scoped to
example.com.also swallowedample.com.andnotexample.com.— a client could land in a group it does not belong to.matchPTRnow requires the suffix to start a label, andptrSuffixesnormalises the config value to FQDN form so the check works even for a config that skippedValidate. - The ACL
*catch-all was dropped from the rendered domain patterns, so the engine's per-queryNewDomainSetrecompile lost the "everything" case. Upstream.Successnever closed an open breaker. A recovered upstream stayed excluded for the whole cooldown — the mirror image of the older bug where a failed exchange marked a dead upstream healthy.Exchangefanned out in parallel even underpolicy: failover. It only took the parallel path when exactly one upstream was chosen, so a failover list of two raced both.- Blocklist: a leading UTF-8 BOM is stripped,
!allow example.comworks,SourceMetaexists for a source that only contributed a rule,Pattern()reports the bare name again (WireName()produces the wildcard form for the API), an RPZ record with no explicit TTL inherits the zone default, a hosts line with no address in the first field says so, the adblock parser handles CRLF and adblock lines pasted into a plain list, and over-long lines are reported rather than accepted. TestHolderSwapIsAtomichad the wrong invariant, not the Holder: after a swap to a set that does not blocka.example.com, that name is legitimately unblocked. The swap is a singleatomic.Pointerstore.- CI could not have passed.
go.modrequires Go 1.27.1; CI pinned 1.23 and the Dockerfile pinnedgolang:1.23-alpine. Both now track the toolchain. - The Dockerfile clobbered the console. It copied the source tree after
the console bundle, and a fresh clone's
internal/web/distholds only.gitkeep. - The k8s set could not have worked.
hostNetwork: truewithreplicas: 2, and the server never sets SO_REUSEPORT, so the second pod could not bind :53. It now runs without hostNetwork behind a LoadBalancer Service, with a separate ClusterIP Service for the Ingress (a headless Service cannot back one), a ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA is gone — it cannot drive a StatefulSet. - The API key could not come from a Secret, because the config had no
variable expansion.
config.Loadnow expands${VAR},${VAR:-default}and${NAME-default}and fails to start on an unset variable with no default, so a missing secret is a crash loop rather than an unauthenticated console. Expansion skips YAML comments, so documenting${VAR}in the ConfigMap does not break the file.
Open decision
- Unknown config keys are silently ignored. Writing
block:where the struct wantsblocklist:produced a plausible-looking server instead of an error.yaml.Decoder.KnownFields(true)would reject it, but that is a behaviour change for anyone carrying extra keys in their config. Not done unilaterally.
No tests yet for
server, api, metrics, cluster. app has integration-level tests that
pass; the rest have no suite at all.
In flight (subagents — do not edit these files)
internal/zonefile+internal/localzone— repair the truncatedlocalzone_test.goand fix the four bugs above.internal/engine+internal/qlog— first suites for the query pipeline and the query log.README.md— written from the source, with a "known inconsistencies" list.
Still to delegate after those: server + api, then metrics + cluster.
Continuation plan, in order
- Land the three in-flight pieces, then re-run
go test -race ./internal/... -count=1 -timeout 300s. internal/serversuite: all four listeners behind one handler, per-listener config, the UDP read-deadline reset (see the design notes), shutdown.internal/apisuite: every route, the three auth modes, the constant-time API-key comparison,X-Forwarded-Forhonoured only fromtrusted_proxies, CORS, rate limiting, and the SSE stream (open a subscriber, assert delivery, unsubscribe, assert no goroutine leak).internal/metrics+internal/cluster: the collectors, the rolling rings, the Valkey lease, pub/sub sync, and divergence detection (aminiredisinstance is the obvious test server).- Decide on
KnownFields(true). - Re-run the console end to end (
/api/healthz,/api/whoami, a page from each route) — it was verified once and the API surface has changed since.
Design notes worth preserving
- Cache stores packed wire bytes, not
*dns.Msg. A hit costs oneUnpackand 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.Pointerholder — 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()ininternal/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-Foris honoured only fromtrusted_proxies. - UDP listener resets its read deadline every iteration. A deadline set once
expires permanently 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. Followers serve their compiled copy.
- Blocked/serve-stale behaviour: a served-stale answer gets TTL 1, an expired upstream answer is never cached, and a truncated answer is never cached (it would be served truncated forever).
PatternKind.Patternis the display form;WireNameis the wildcard form. The internal*./**.prefixes must not leak into the UI, but the API needs them to re-add a rule unchanged.
Test-hygiene traps
tsc -breplays cached diagnostics fromnode_modules/.tmp/*.tsbuildinfo. After a large edit,rm node_modules/.tmp/*.tsbuildinfo.vite buildemptiesoutDirand deletesinternal/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. - An agent that runs out of budget mid-append leaves a file that does not
parse.
gofmt -lon the package is the fastest way to see it.