Files
fancy-dns/AGENTS.md
T
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

199 lines
11 KiB
Markdown

# 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.Pointer` holder** — 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 one
`Unpack` and 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.size` alone
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()` in `internal/app/hooks.go`).
- **API keys are compared by SHA-256 hash, constant-time**, and
`X-Forwarded-For` is honoured *only* from `trusted_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/src` too.
- **`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.
- **The Go heap does not account for goroutine stacks, runtime metadata, or the
bbolt mmap.** `memory.reserve` exists 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
`TestLoadIgnoresUnknownKeys` in `internal/config`: `yaml`
`KnownFields(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.Nsid` is 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.NewRR` needs 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.Server` fakes on `127.0.0.1:0`: `net.Listen`/`net.ListenPacket` to
find the port, `ActivateAndServe` in a goroutine, stop in `t.Cleanup`.
- A `dns.Msg` carrying a `TXT` string over 255 bytes **does not pack** in
miekg/dns v1.1.73 — the error is `dns: 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.Nsid` is a hex string, not the bytes on the wire.** The
library hex-decodes it in `pack` and hex-encodes it in `unpack`, so a plain
value such as `"fancy-dns"` fails with `dns: overflow packing opt` and a
long NSID packs fine (unlike `TXT`). This bit the engine: an unencoded
literal made every query from an NSID-using client SERVFAIL. Always
hex-encode on the way in; `TestPinnedEDNS0NSIDIsHex` in
`internal/engine/pins_test.go` pins the contract.
- `internal/app/integration_test.go` already builds a fully wired app. Reuse its
harness rather than rebuilding one.
- `tsc -b` replays **cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`.
After a large frontend edit: `rm node_modules/.tmp/*.tsbuildinfo`.
- `vite build` empties `outDir` and **deletes `internal/web/dist/.gitkeep`**.
Restore it or the directory disappears from git.
- The Go binary embeds `internal/web/dist`, so `npm ci && npm run build` must run
before a fresh clone will build a binary with the console in it.
- `make build` depends on `web` and shells out to npm. `make go-build` skips 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-For` is only trusted from `trusted_proxies`,
and `httpRemoteAddr` (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 in `Load` and 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
plain `os.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.