11 KiB
AGENTS.md — working on fancy-dns
Project-specific knowledge for anyone (human or agent) changing this repo.
General state and the continuation plan live in STATUS.md; read that first for
what is done and what is next. This file is the part that should not change
every week: the decisions, the invariants, and the rules that have been learned
the hard way.
What this is
A recursive DNS server in Go with an embedded React console. Config is YAML plus RFC 1035 master files, hot-reloaded over SIGHUP. Blocklists in four formats, a sharded LRU cache with a bbolt snapshot for warm restart, per-client ACLs, query logging, Prometheus metrics, and optional Valkey/Redis for cross-instance sync.
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, 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.
Decisions already made — do not relitigate
| 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 | deliberately out of scope; AD/CD/extended-RCODE are passed 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; scale by adding replicas |
| Ingress | A separate ClusterIP Service | a headless Service has no virtual IP for an Ingress to route to |
| Profiling | pprof refused unless admin is loopback | the handlers sit outside the API's auth middleware |
Invariants
Break these and you break the design, not just the test.
- Everything the query path reads is behind an
atomic.Pointerholder — ACL, blocklist, localzone, upstream pool. Readers load the pointer once and work against a value that cannot change under them. A lock on the hot path is a regression. Prefer building a new immutable snapshot and swapping the pointer. - Cache entries are 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. - The cache is bounded by bytes as well as by entry count.
cache.sizealone bounds nothing: one cached ANY or TXT answer can be tens of kilobytes. - Serve-stale answers are served with TTL 1. An expired upstream answer is never cached. A truncated answer is never cached — it would be served truncated forever.
- The UDP read deadline is reset every iteration. A deadline set once expires for good and turns the loop into a spin that never reads another packet.
- Only the Valkey lease holder fetches blocklists, so N instances do not hit a mirror N times. Followers serve their compiled copy.
- Blocklist changes flush the cache rather than guessing which subtrees are
affected (
invalidateBlocklistCache()ininternal/app/hooks.go). - API keys are compared by SHA-256 hash, constant-time, and
X-Forwarded-Foris honoured only fromtrusted_proxies. Honouring it from anyone lets a client spoof its identity past the ACL and the rate limiter. - The API response field names are shared with the frontend. If you rename
one, grep
web/srctoo. 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.- The Go heap does not account for goroutine stacks, runtime metadata, or the
bbolt mmap.
memory.reserveexists to cover them; the cache snapshot and query log are mmapped and live outside the heap entirely. - Unknown config keys are silently ignored (a settled decision, pinned by
TestLoadIgnoresUnknownKeysininternal/config:yamlKnownFields(true)was considered and rejected as a behaviour change for anyone carrying extra keys). If a config key seems to have no effect, check the spelling against the struct tags before anything else. The leniency covers unknown keys only — a known key with a bad type is still a startup failure. dns.EDNS0_NSID.Nsidis hex on the wire. A human-readable value there builds a message that will not pack, so the client gets SERVFAIL. See the test-hygiene traps below.
Working rules
Never leave the tree red. You have a hard budget and no way to detect you are close to it. Whatever is on disk at the end of any round must compile and pass. Get a few tests running early and keep them running. If you are running long, stop adding tests and make the existing ones pass — a smaller green suite is worth far more than a larger red one.
Scope your edits. Check git status before you start; other work may be in
flight. Stage only the packages you touched. .codesearch/ is tracked but
gitignored — never stage it. If you edit a package someone else is also editing,
say so in your report rather than silently overwriting.
Verify before you claim. make check-go is the fast loop (gofmt, vet,
compile, ~1s). make check adds the full test suite. Push only a green tree.
Commit and push when the task says so. Read git log --oneline -5 and
git show --stat HEAD first and match the style: an imperative subject line, then
one paragraph per bug covering root cause and fix. A commit that says what was
changed without saying why is not finished work.
When a test fails, decide which of three things is wrong before editing anything: the test, the implementation, or the specification. All three happen here. Say which one you concluded and why. Do not relax an assertion to make a suite pass — if an existing assertion pins buggy behaviour, change it and say so explicitly in the commit message, because that decision belongs in the history.
Report honestly. If you did not finish, say what is unfinished and what state the tree is in. A partial result that is accurately described is usable; a partial result described as done is not.
Test-hygiene traps
dns.NewRRneeds a TTL column:dns.NewRR("host.example.com")does not parse. A failure there is a bad fixture, 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.
- Spin up
dns.Serverfakes on127.0.0.1:0:net.Listen/net.ListenPacketto find the port,ActivateAndServein a goroutine, stop int.Cleanup. - A
dns.Msgcarrying aTXTstring over 255 bytes does not pack in miekg/dns v1.1.73 — the error isdns: string exceeded 255 bytes in txt, and 255 is the exact boundary. On a fake upstream's write path that failure is silent and presents as a client timeout or a SERVFAIL several layers away. Build a multi-kilobyte answer out of many strings under 255 bytes, and assert the fake's write error before blaming the client. dns.EDNS0_NSID.Nsidis a hex string, not the bytes on the wire. The library hex-decodes it inpackand hex-encodes it inunpack, so a plain value such as"fancy-dns"fails withdns: overflow packing optand a long NSID packs fine (unlikeTXT). This bit the engine: an unencoded literal made every query from an NSID-using client SERVFAIL. Always hex-encode on the way in;TestPinnedEDNS0NSIDIsHexininternal/engine/pins_test.gopins the contract.internal/app/integration_test.goalready builds a fully wired app. Reuse its harness rather than rebuilding one.tsc -breplays cached diagnostics fromnode_modules/.tmp/*.tsbuildinfo. After a large frontend edit:rm node_modules/.tmp/*.tsbuildinfo.vite buildemptiesoutDirand deletesinternal/web/dist/.gitkeep. Restore it or the directory disappears from git.- The Go binary embeds
internal/web/dist, sonpm ci && npm run buildmust run before a fresh clone will build a binary with the console in it. make builddepends onweband shells out to npm.make go-buildskips the console and is what you want in an inner loop.
Security-sensitive areas
These have been wrong before. Changes here need a test that fails without the fix.
- Auth (
internal/api/auth.go): API key comparison must stay constant-time; the key is hashed before comparison so the plaintext is not in a long-lived string. - Client identity:
X-Forwarded-Foris only trusted fromtrusted_proxies, andhttpRemoteAddr(used by the DoH handler) deliberately does not consult it at all. Behind an Ingress every DoH client therefore lands in the proxy's ACL group — fail-closed if that group is restrictive, fail-open if it is permissive. Pin this behaviour with a test before changing it. - Config expansion:
${VAR}is expanded inLoadand an unset variable with no default is fatal, so a missing Secret is a crash loop rather than an unauthenticated console. The expansion works inside quoted scalars — that is the form operators write — and skips YAML comments. Do not "simplify" it into a plainos.ExpandEnv; that silently breaks the API key path. - pprof is loopback-only by construction, so no config flag can turn it into a network-reachable unauthenticated endpoint. Keep it that way.
- Amplification: the DoH/UDP handlers cap the client's advertised EDNS0 buffer. Do not remove the cap.