11 KiB
fancy-dns — development status and continuation plan
Last updated: session checkpoint. Everything below reflects the state of the working tree at the time of writing, including known-failing tests and the exact next command to run.
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 |
Package layout
cmd/fancy-dns/main.go entrypoint: flags, signals, SIGHUP reload
internal/
config/ Config struct, Duration type, Validate, atomic Store + hooks
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 (subagent-built)
deploy/ example config, example zones
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
- All packages compile;
go vet ./...clean. go test ./internal/app/passes (integration suite, ~20 tests) with a fake upstream on loopback: resolve/caching, all blocklist shapes, allow-over-block, local zones (exact/wildcard/NODATA/NXDOMAIN), ACL deny + allow_domains + skip_blocklist, all four block actions, malformed query FORMERR, TCP parity, API auth (401/403/200), domain add→blocked→delete round trip, ACL update, upstream update, settings validate/save + rejected-write-leaves-file-alone, blocklist toggle both directions, dashboard/status shape, logs, cache endpoints, reload failure keeps serving + success clears error, SSE stream emitsreadyandentry, metrics protected/unprotected, concurrency smoke.- Verified live end-to-end against the real binary on :15353/:18080 with dig.
- Subagent-verified working examples of
/api/domains/checkfor hosts-file, RPZ-wildcard and inline rules.
Known failures (fix these first)
go test -race ./internal/app/still reports DATA RACE and two failures.TestDomainAddDeleteRoundTripandTestUpstreamUpdatefail only under-race(they pass without it), which points at timing, not logic.- I had just started fixing races by moving
engine.sem,engine.semSizeandengine.upstreamTimeoutbehind atomics. TheapplyConfighook now writese.sem.Store(&sem);Handledoessem := e.sem.Load(). This refactor is mid-flight — re-readinternal/engine/engine.gobefore touching it. Remaining races are almost certainly inconfig.Configfield access fromapplyConfig(e.g.a.pool.Rebindcalled from the swap hook while queries readcfg), and inharness_test.goreading the config file the server rewrites (partially fixed by snapshottingdocbeforeStart— verify). internal/app/dbg_test.gowas deleted; do not reintroduce it.
- Frontend
web/is incomplete. The foundation/pages subagents each ran out of budget.src/lib/types.ts,format.ts,cn.ts,vite.config.tsexist;src/index.cssis still the purple Vite template;src/lib/api.ts,src/hooks/*,src/components/ui/*,src/components/layout/*and all ofsrc/pages/*are missing.npm run buildandnpx tsc -b --noEmithave never passed.internal/web/dist/index.htmlis a placeholder so the binary compiles. - No unit tests outside
internal/app/.blocklist,cache,zonefile,acl,resolver,localzonehave no direct tests. - Missing deliverables:
Makefile,.gitignore,Dockerfile, k8s manifests, CI workflow,README.md.
Continuation plan, in order
Step 1 — green tests (highest priority)
go build ./... && go vet ./...
go test -race ./internal/app/ -count=1 2>&1 | grep -A20 "DATA RACE" | head -60
Expected races to fix, in likelihood order:
config.Storeswap hook runningapp.onConfigSwap→pool.RebindandreloadLocalZoneswhile the query path reads the old*config.Config. Fix direction: makePool.Rebindbuild a new slice and swap anatomic.Pointerrather than mutating in place undersync.RWMutexwhile readers holdRLockon a different field.blocklist.Managerm.runningmap vsRefreshAllgoroutine + API handler. Already mutex-guarded; confirm it is the Stats pointer that races.qlog.Logger.livering:Logwritesl.live[pos%len]from many goroutines whileRecentreads. Needs a seqlock or a per-slot atomic pointer.- Harness reading the config file path from a test goroutine.
For each race, prefer removing shared mutable state over adding locks: the
design already uses atomic.Pointer holders for ACL, blocklist, localzone and
pool; extend that pattern.
Step 2 — unit tests for the hot packages
Priority order (highest bug-density first):
blocklist:Set.Matchfor*.x(subdomains only) vs**.x(apex too) vs exact; allow-over-block priority; hosts/adblock/plain/rpz parser cases;DetectFormatsniffing; duplicate handling.cache: LRU eviction, TTL clamping,TTLForon NXDOMAIN/NODATA/SERVFAIL, serve-stale window, key pack/unpack round-trip, bbolt snapshot→load.zonefile:$TTLinheritance, omitted owner/TTL/class, parens,@,$ORIGINrelative vs absolute names,ParseTTL/SerialNext.acl: first-match-wins, most-specific CIDR, PTR suffix,DomainSet.Match.localzone: delegation cut, closest-wildcard (RFC 1034 §4.3.3), NODATA.resolver: breaker open/close, parallel race, TCP fallback on truncation.- Add
BenchmarkLookupforSet.Matchandcache.LRU.Getto substantiate the "great performance" claim.
Step 3 — finish the frontend
Respawn two subagents with disjoint file ownership (this worked structurally; they just need more budget):
- Foundation agent owns
src/index.css(replace the purple template with dark-first CSS vars + one emerald accent),src/lib/api.ts,src/hooks/*,src/components/ui/*,src/components/layout/*. The exact prop contract I gave them is in the transcript and must be kept. - Pages agent owns
src/pages/*only, coding against the same contract.
Then wire src/App.tsx + src/main.tsx yourself (react-router, AppShell,
ToastProvider, theme init) and run npm run build. Once web/dist is real,
the //go:embed all:dist in internal/web/web.go picks it up with no Go
change; delete the placeholder internal/web/dist/index.html.
API contract for the frontend is fixed and implemented — see
internal/api/handlers.go route table and internal/app/hooks.go payload
shapes. /api/logs/stream is SSE with events ready, entry, stats, and
X-Accel-Buffering: no.
Step 4 — deploy + docs
Makefile:build,test,race,vet,web,lint,run,image.Dockerfile: multi-stage — node build forweb/, then Go build with-ldflags "-X ...app.Version=..."; distroless/scratch;EXPOSE 53/udp 53/tcp 853/tcp 443/tcp 8080/tcp; non-root;VOLUME /var/lib/fancy-dns.- k8s: Deployment (hostNetwork or a NodePort Service for :53), Service,
ConfigMap for the YAML, PVC for the bbolt files, readiness probe on
/api/healthz, optional Ingress for the console. - CI:
go vet,go test -race,gofmt -l,npm ci && tsc -b && npm run build. README.md: quickstart, config reference, zone-file semantics, API reference, performance notes, the security caveats that actually matter (trusted_proxiesandX-Forwarded-For;metrics.protect; thetrust_loopback+auth.modematrix).
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. - Cache invalidation on rule changes flushes the cache rather than
guessing which subtrees are affected. A newly blocked name must not keep
serving its cached good answer, and a new allow rule can rescue a name many
lists blocked.
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— otherwise a client could spoof its identity past the ACL and the rate limiter. - 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 (this was a real bug 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).