diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db index 28ad0b5..14925ed 100644 Binary files a/.codesearch/codesearch.db and b/.codesearch/codesearch.db differ diff --git a/.codesearch/session.json b/.codesearch/session.json index 6ac49a1..36e8fa9 100644 --- a/.codesearch/session.json +++ b/.codesearch/session.json @@ -1,55 +1,336 @@ { "root": "/workspace/repos/git.k6n.net/mats/fancy-dns", "recent_files": [ + "internal/cluster/cluster_test.go", + "README.md", + "internal/server/shutdown_test.go", + "internal/resolver/resolver_test.go", "web/src/pages/Upstreams.tsx", + "internal/api/handlers.go", "internal/config/config.go", - "internal/app/harness_test.go", - "internal/server/stats.go" + "internal/api/settings_test.go", + "web/src/lib/format.ts", + "deploy/k8s/10-deployment.yaml", + "web/src/lib/api.ts", + "web/src/components/ui/Table.tsx", + "internal/acl/acl_test.go", + "internal/config/size_test.go", + "internal/config/config_test.go", + "internal/resolver/pool.go", + "internal/acl/acl.go", + "internal/engine/engine_test.go", + "internal/blocklist/set.go", + "internal/blocklist/parse.go", + "internal/app/memory.go", + "internal/api/auth.go", + "internal/app/app.go", + "internal/api/api.go", + "internal/zonefile/zonefile_test.go", + "internal/engine/pins_test.go", + "internal/engine/harness_test.go", + "internal/metrics/metrics.go", + "internal/qlog/counts.go", + "internal/metrics/metrics_test.go" ], "queries": [ { - "query": "(?i)/api/", + "query": "func Load|KnownFields|yaml.NewDecoder|func Marshal", "command": "grep", - "at": "2026-09-27T11:00:15.297072822Z", + "at": "2026-09-27T16:22:55.810381382Z", "focus_path": "internal/config/config.go" }, { - "query": "(?i)json:\"(degraded_reason|p99_ms|recent|latency|fields|error)", + "query": "NSID|OutcomeLocal|ActionAllow|ActionLocal", "command": "grep", - "at": "2026-09-27T11:00:17.585327659Z", - "focus_path": "internal/server/stats.go" + "at": "2026-09-27T16:22:55.85487649Z", + "focus_path": "internal/engine/engine_test.go" }, { - "query": "type (Upstream|UpstreamServer|Bootstrap|Duration) |func \\(d Duration\\)", + "query": "^func Test", "command": "grep", - "at": "2026-09-27T11:23:32.967578183Z", + "at": "2026-09-27T16:23:07.872849833Z", + "focus_path": "internal/config/size_test.go" + }, + { + "query": "Outcome|func Record\\(|local", + "command": "grep", + "at": "2026-09-27T16:23:09.794989995Z", + "focus_path": "internal/metrics/metrics_test.go" + }, + { + "query": "func |local|Local", + "command": "grep", + "at": "2026-09-27T16:23:24.799237174Z", + "focus_path": "internal/qlog/counts.go" + }, + { + "query": "func (Handler|Handler\\(\\)|Record|Set|DefaultGatherer|prometheus.Handler)", + "command": "grep", + "at": "2026-09-27T16:23:24.807494518Z", + "focus_path": "internal/metrics/metrics.go" + }, + { + "query": "(?i)^func |^type ", + "command": "grep", + "at": "2026-09-27T16:23:28.81552169Z", + "focus_path": "internal/engine/harness_test.go" + }, + { + "query": "testZone = |const testZone", + "command": "grep", + "at": "2026-09-27T16:23:49.801307302Z", + "focus_path": "internal/engine/engine_test.go" + }, + { + "query": "^type Config struct|^\\tBlock |^\\tCache |^\\tAPI |^\\tDNS |^\\tMemory |^\\tUpstream |^\\tACL |^\\tServer |^\\tLocalZone |^\\tQueryLog |^\\tMetrics ", + "command": "grep", + "at": "2026-09-27T16:24:06.794863351Z", "focus_path": "internal/config/config.go" }, { - "query": "type (ACL|ACLGroup|ACLMatch) struct|PTRSuffixes|CIDRs|DefaultPolicy|DefaultRateLimit", + "query": "(?i)map\\[", "command": "grep", - "at": "2026-09-27T11:23:32.969105642Z", + "at": "2026-09-27T16:24:08.80916393Z", "focus_path": "internal/config/config.go" }, { - "query": "auth_mode|authMode|\"apikey\"|\"password\"|whoami", + "query": "EDNS0_NSID|EDNS0NSID|Nsid", "command": "grep", - "at": "2026-09-27T11:24:44.430733848Z", - "focus_path": "internal/app/harness_test.go" + "at": "2026-09-27T16:28:04.258015751Z", + "focus_path": "internal/engine/pins_test.go" }, { - "query": "logs/stream|clearLogs|logs/clear|persisted|serve_stale", + "query": "^const|^\\tdefaultPort|^\\t[a-zA-Z]+ = ", "command": "grep", - "at": "2026-09-27T11:24:44.441840735Z", + "at": "2026-09-27T16:28:34.803806079Z" + }, + { + "query": "const \\(|^\\tmaxCacheKey|= uint16\\(", + "command": "grep", + "at": "2026-09-27T16:28:37.79165687Z" + }, + { + "query": "NSID|Changelog|CHANGELOG|hide_identity|outcome=", + "command": "grep", + "at": "2026-09-27T16:29:36.797031137Z", + "focus_path": "README.md" + }, + { + "query": "os.Stat|os.Open|func \\(c \\*Config\\) Validate", + "command": "grep", + "at": "2026-09-27T16:30:29.818748722Z", "focus_path": "internal/config/config.go" }, { - "query": "(?i)^import .*from '\\.\\./\\.\\./(lib|hooks)/.*'$", + "query": "ParseFiles\\(|ParseFile\\(", "command": "grep", - "at": "2026-09-27T11:28:04.46304807Z", + "at": "2026-09-27T16:30:40.7838816Z", + "focus_path": "internal/zonefile/zonefile_test.go" + }, + { + "query": "func decodeJSON|type LogEntry|func isFileWritable", + "command": "grep", + "at": "2026-09-27T16:30:49.816227401Z", + "focus_path": "internal/api/api.go" + }, + { + "query": "func ParseRPZ|rpz-drop|rpz-passthru|Origin", + "command": "grep", + "at": "2026-09-27T16:31:10.849412582Z", + "focus_path": "internal/blocklist/parse.go" + }, + { + "query": "(?i)^func |^type ", + "command": "grep", + "at": "2026-09-27T16:31:43.627702877Z", + "focus_path": "internal/blocklist/set.go" + }, + { + "query": "1Gi|GiB|rejects|wantErr", + "command": "grep", + "at": "2026-09-27T16:34:42.749387182Z", + "focus_path": "internal/config/size_test.go" + }, + { + "query": "(?i)web\\.|internal/web", + "command": "grep", + "at": "2026-09-27T16:34:42.761506244Z", + "focus_path": "internal/app/app.go" + }, + { + "query": "local_zones|policy zone|rpz|\\$ORIGIN|relative", + "command": "grep", + "at": "2026-09-27T16:35:03.10318301Z", + "focus_path": "README.md" + }, + { + "query": "Cookie|SetCookie|session|Session", + "command": "grep", + "at": "2026-09-27T16:39:07.351703772Z", + "focus_path": "internal/api/auth.go" + }, + { + "query": "type (Stats|Source|Entry) struct", + "command": "grep", + "at": "2026-09-27T16:39:54.62688853Z", + "focus_path": "internal/resolver/pool.go" + }, + { + "query": "MarshalJSON|json:\\\"", + "command": "grep", + "at": "2026-09-27T16:40:03.815051663Z", + "focus_path": "internal/app/memory.go" + }, + { + "query": "json:\"|MarshalJSON|UnmarshalJSON", + "command": "grep", + "at": "2026-09-27T16:40:08.967374706Z", + "focus_path": "internal/blocklist/parse.go" + }, + { + "query": "type (SourceMeta|ParseStats|State) struct", + "command": "grep", + "at": "2026-09-27T16:40:08.97960362Z", + "focus_path": "internal/blocklist/set.go" + }, + { + "query": "func \\(m \\*Manager\\) (State|RefreshAll)|func \\(s \\*Set\\) Lookup|func \\(s \\*Set\\) Each", + "command": "grep", + "at": "2026-09-27T16:40:27.808696066Z", + "focus_path": "internal/blocklist/set.go" + }, + { + "query": "func ParseAction|func \\(a Action\\) String|type Action |func \\(s \\*?Server\\) GroupStats|GroupStats\\(", + "command": "grep", + "at": "2026-09-27T16:40:27.833390906Z", + "focus_path": "internal/engine/engine_test.go" + }, + { + "query": "type GroupSummary struct", + "command": "grep", + "at": "2026-09-27T16:40:30.807130196Z", + "focus_path": "internal/acl/acl.go" + }, + { + "query": "type (Group|GroupMatch|Upstream|UpstreamServer|UpstreamBootstrap|ListSource) struct", + "command": "grep", + "at": "2026-09-27T16:40:40.82006076Z", + "focus_path": "internal/resolver/pool.go" + }, + { + "query": "(?i)json:\"", + "command": "grep", + "at": "2026-09-27T16:40:57.785690297Z", + "focus_path": "internal/config/config_test.go" + }, + { + "query": "type Duration|UnmarshalJSON|MarshalJSON|UnmarshalYAML", + "command": "grep", + "at": "2026-09-27T16:40:57.789620873Z", + "focus_path": "internal/config/size_test.go" + }, + { + "query": "(?i)allow_domains|/api/clients|/api/upstreams", + "command": "grep", + "at": "2026-09-27T16:40:57.827427347Z", + "focus_path": "internal/acl/acl_test.go" + }, + { + "query": "\\bas (any|unknown|[A-Z][A-Za-z]+)", + "command": "grep", + "at": "2026-09-27T16:41:11.843903227Z", "focus_path": "web/src/pages/Upstreams.tsx" + }, + { + "query": "as \\{|as unknown|as any|\u003cT\u003e\\(|\\?\\? \\{\\}", + "command": "grep", + "at": "2026-09-27T16:41:15.51505886Z", + "focus_path": "web/src/components/ui/Table.tsx" + }, + { + "query": "(?i)whoami|healthz|/api/status|read_only|can_write|base_path", + "command": "grep", + "at": "2026-09-27T16:41:21.828861369Z", + "focus_path": "web/src/lib/api.ts" + }, + { + "query": "(?i)zones|api/zones", + "command": "grep", + "at": "2026-09-27T16:42:50.623250022Z", + "focus_path": "deploy/k8s/10-deployment.yaml" + }, + { + "query": "(?i)qtype_name|qtype|latency|p99_ms|can_write|read_only", + "command": "grep", + "at": "2026-09-27T16:47:26.210261153Z", + "focus_path": "web/src/lib/format.ts" + }, + { + "query": "[Rr]ate[Ll]imit", + "command": "grep", + "at": "2026-09-27T16:47:29.878195015Z", + "focus_path": "internal/api/settings_test.go" + }, + { + "query": "MinVersion|min_version", + "command": "grep", + "at": "2026-09-27T16:48:52.789077804Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "BasePath|base_path", + "command": "grep", + "at": "2026-09-27T16:50:15.791788416Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "ByTransport|TransportStats|Stats\\(\\)", + "command": "grep", + "at": "2026-09-27T17:03:08.354985355Z", + "focus_path": "internal/api/handlers.go" + }, + { + "query": "(?i)sent|truncated|dropped", + "command": "grep", + "at": "2026-09-27T17:03:08.381477376Z", + "focus_path": "web/src/pages/Upstreams.tsx" + }, + { + "query": "ByTransport|\\.Stats\\(\\)", + "command": "grep", + "at": "2026-09-27T17:03:18.113980139Z", + "focus_path": "internal/resolver/resolver_test.go" + }, + { + "query": "waitPortFree|waitPortRefused|func bindFree|TestInFlight", + "command": "grep", + "at": "2026-09-27T17:06:50.821245718Z", + "focus_path": "internal/server/shutdown_test.go" + }, + { + "query": "captureHandler|keyValue|miniredisNew", + "command": "grep", + "at": "2026-09-27T17:13:26.564777546Z", + "focus_path": "internal/cluster/cluster_test.go" + }, + { + "query": "(?i)router\\.lan|\\*\\.lan|ns1\\.lan|local\\.example\\.zone|doubleclick", + "command": "grep", + "at": "2026-09-27T17:13:50.882965183Z", + "focus_path": "README.md" + }, + { + "query": "waitSubscribed\\(t, ", + "command": "grep", + "at": "2026-09-27T17:13:57.526450829Z", + "focus_path": "internal/cluster/cluster_test.go" + }, + { + "query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written", + "command": "grep", + "at": "2026-09-27T17:14:02.764934975Z" } ], "ast_pins": [], - "updated_at": "2026-09-27T11:28:04.46305757Z" + "updated_at": "2026-09-27T17:14:02.764941775Z" } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3d9b1c..568aca4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,11 +184,29 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build + # `github.sha` exists on GitHub and on Gitea (which aliases the github + # context). The `gitea.*` context only exists on Gitea, so a workflow + # under .github/ that used it could never tag an image here. + # + # The image is published by .gitea/workflows/build.yaml on the real + # runner, which is the one with registry access; this job only pushes + # when this repository has been given a token for it, so a GitHub runner + # without credentials builds and stops instead of failing the pipeline. + - name: Build run: | - docker build -t $IMAGE:${{ gitea.sha }} -t $IMAGE:latest . + docker build --build-arg VERSION=${{ github.sha }} \ + -t $IMAGE:${{ github.sha }} -t $IMAGE:latest . + + - name: Log in to the registry + if: ${{ secrets.FANCYDNS_REGISTRY_TOKEN != '' }} + uses: docker/login-action@v3 + with: + registry: registry.k6n.net + username: ${{ secrets.FANCYDNS_REGISTRY_USER }} + password: ${{ secrets.FANCYDNS_REGISTRY_TOKEN }} - name: Push image + if: ${{ secrets.FANCYDNS_REGISTRY_TOKEN != '' }} run: | - docker push $IMAGE:${{ gitea.sha }} + docker push $IMAGE:${{ github.sha }} docker push $IMAGE:latest \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c49be4b..7a69ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,9 +98,16 @@ Break these and you break the design, not just the test. - **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 known open decision — - `yaml` `KnownFields(true)` would reject them). If a config key seems to have no - effect, check the spelling against the struct tags before anything else. +- **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 @@ -144,13 +151,19 @@ partial result described as done is not. 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 character-string over 255 bytes **does not pack** in - miekg/dns v1.1.73: `TXT.len()` reports such a string as zero length and - `EDNS0_NSID.len()` is likewise wrong, so `Pack` fails with "dns: overflow - packing opt". 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. +- 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`. diff --git a/Makefile b/Makefile index da2db7b..615e987 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ fmt-check: ## Fail if anything is unformatted fi .PHONY: lint -lint: ## Vet plus a gocyclo-style sanity check on the biggest package +lint: ## go vet, with the name it has always had $(GO) vet ./... @echo "lint: ok" @@ -118,13 +118,41 @@ web-check: ## Typecheck and build the console ## Run .PHONY: run -run: go-build ## Run with deploy/config.example.yaml on unprivileged ports +run: go-build ## Run with deploy/config.example.yaml (binds :53 — rewrite the ports first) $(DIST)/$(BINARY)-noassets -config deploy/config.example.yaml -log-level debug .PHONY: check-config check-config: go-build ## Validate a config file without starting $(DIST)/$(BINARY)-noassets -config $(or $(CONFIG),deploy/config.example.yaml) -check +## End-to-end + +.PHONY: smoke +smoke: build ## Boot the real binary on unprivileged ports; check the API, metrics and the embedded console + @set -euo pipefail; \ + dir=$$(mktemp -d); \ + pid=""; \ + trap '[ -n "$$pid" ] && kill "$$pid" 2>/dev/null || true; rm -rf "$$dir"' EXIT; \ + sed \ + -e 's#127.0.0.1:8080#127.0.0.1:18080#' \ + -e 's#0.0.0.0:53#127.0.0.1:15353#g' \ + -e 's#\[::\]:53#[::1]:15353#g' \ + -e 's#trust_loopback: true#trust_loopback: false#' \ + deploy/config.example.yaml > "$$dir/config.yaml"; \ + $(DIST)/$(BINARY) -config "$$dir/config.yaml" -check; \ + $(DIST)/$(BINARY) -config "$$dir/config.yaml" > "$$dir/log" 2>&1 & \ + pid=$$!; \ + for i in $$(seq 1 40); do \ + curl -fsS http://127.0.0.1:18080/api/healthz >/dev/null 2>&1 && break; \ + sleep 0.5; \ + done; \ + echo "==> health"; curl -fsS http://127.0.0.1:18080/api/healthz; echo; \ + echo "==> console"; curl -fsS http://127.0.0.1:18080/ | grep -qi ' auth"; \ + test "$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/api/whoami)" = 401 && echo "unauthenticated -> 401"; \ + curl -fsS -H "X-API-Key: CHANGE-ME-long-random-string" http://127.0.0.1:18080/api/whoami | grep -q '"can_write":true' && echo "authenticated -> can_write"; \ + echo "==> metrics"; curl -fsS -H "X-API-Key: CHANGE-ME-long-random-string" http://127.0.0.1:18080/api/metrics | grep -c '^fancydns_' + ## Container .PHONY: image diff --git a/README.md b/README.md index bec3f7e..1680acc 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,16 @@ the file wins, because `Parse` starts from that derived origin and the directive replaces it. Records that fall outside their own zone are dropped (`localzone.add` returns false). +The practical consequence: a *relative* name is qualified with the file's base +name, not with the zone you had in mind. In `local.example.zone` (origin +`local.`) the record `ns1 A 192.168.1.2` becomes `ns1.local.`, and in a policy +zone `*.doubleclick.net` becomes `*.doubleclick.net.local.` — a rule that blocks +nothing you asked about. Both shipped examples therefore spell every name out in +full, which is the only spelling that does not depend on what the file is +called: `TestExampleLocalZoneIsOriginIndependent` (`internal/zonefile`) and +`TestExampleRPZZoneBlocksWhatItSays` (`internal/blocklist`) fail if they are +"tidied up" back to relative names. + **Policy zone vs ordinary local zone.** Both come from `blocklist.local_zones` and both are master files; the difference is what the records mean and where they are matched: @@ -403,7 +413,7 @@ records mean and where they are matched: | | RPZ-style policy zone | Ordinary local zone | |---|---|---| | Compiled by | `blocklist.parseRPZ` / `parseRPZFiles` | `localzone.Load` | -| Matching | by rule name; `*.name` covers subdomains; the rdata carries the action | by the question: exact `name\|TYPE`, then `*.`, then any type at the name | +| Matching | by rule name; `*.name` covers subdomains but **not** the name itself (see [Known inconsistencies](#known-inconsistencies)); the rdata carries the action | by the question: exact `name\|TYPE`, then `*.`, then any type at the name | | Action from rdata | `CNAME .` → NXDOMAIN, `CNAME rpz-drop.` → drop, `CNAME rpz-passthru.` / `rpz-tcp-only.` → passthrough, other CNAME → CNAME answer, `A`/`AAAA` → that address, `TXT` containing `allow` → allow | none; records are answers, and the zone's SOA is returned for NXDOMAIN/NODATA | | In the pipeline | after the ACL, before the cache | after the ACL, before the blocklist | | Delegation | n/a | an `NS` record below the apex marks a delegation and the data below it is not answered | @@ -784,4 +794,27 @@ Found while writing this file; each is a to-do, not something fixed here. rejects the combination with `trust_loopback: false`; the unsafe case is reported solely as a warning from `POST /api/settings/validate`. 24. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys - are ignored silently (also recorded as an open decision in `STATUS.md`). + are ignored silently. A settled decision (pinning, not changing, the + behaviour); `TestLoadIgnoresUnknownKeys` in `internal/config` holds it. +25. **`internal/blocklist/parse.go:485-495` — an RPZ record does not cover the + name it names.** `rpzRule` gives a bare owner name the subdomain form + (`*.name`), and a subdomain-form rule matches that name's children but not + the name itself, so `analytics.example.org.` in a policy zone blocks + `cdn.analytics.example.org.` and leaves `analytics.example.org.` resolving + normally. RPZ semantics say the record applies to the domain and everything + under it, so the apex form (`**.name`) is what belongs here — a + one-character change in `rpzRule` that alters which queries get blocked, so + it wants a decision rather than a quiet fix. Pinned by + `TestExampleRPZZoneApexIsNotCovered`. The same gap means an + `rpz-passthru.` record for a domain does not allowlist the domain itself. +26. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the + request's `Origin` *and* sets `Access-Control-Allow-Credentials: true`.** + That is deliberate (a literal `*` is ignored by browsers when credentials + are involved, so echoing is the only way a wildcard can work with a session + token), but it does mean a wildcard CORS origin lets any website drive the + admin API with whatever the browser already has cached for that host — + relevant to `auth.mode: basic` and to a loopback-trusted console. The + preflight also answers 204 for *any* origin and any path, and credentials + are advertised for exact origins too. Pinned by + `TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight`; whether a + wildcard should be refused outright is a decision. diff --git a/STATUS.md b/STATUS.md index 35c359b..ffaf233 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,7 +1,9 @@ # fancy-dns — development status and continuation plan -Last updated when the whole tree went green. `AGENTS.md` holds the decisions, -invariants and working rules; this file holds state and the plan. +Last updated after the round that finished the test coverage of every package +that has logic worth covering, fixed two live bugs and pinned the open +questions. `AGENTS.md` holds the decisions, invariants and working rules; this +file holds state and the plan. ## Goal @@ -28,6 +30,7 @@ A lightweight, high-performance recursive DNS server with: | 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 | +| Unknown YAML keys | Ignore, loudly documented | `KnownFields(true)` was considered and rejected as a behaviour change for anyone carrying extra keys. Pinned by `TestLoadIgnoresUnknownKeys`. | ## Package layout @@ -48,7 +51,8 @@ internal/ 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 + app/ wiring, lifecycle, API hook implementations, memory envelope, + and the response contract the console is written against web/ go:embed of the built console web/ Vite + React + TS + Tailwind console deploy/ example config, example zones, k8s manifests @@ -66,221 +70,254 @@ Cache is a single shard-local lookup; only a miss touches the network. ## Status -### Green — the whole tree passes +### Green, end to end -`gofmt` clean, `go build ./...` clean, `go vet` clean. +`gofmt` clean, `go build ./...` clean, `go vet ./...` clean, and +`go test -race -count=1 -timeout 300s ./...` green across every package. -`go test -race ./internal/... -count=1 -timeout 300s` is **green across every -package that has a suite**: `acl`, `app`, `blocklist`, `cache`, `config`, -`engine`, `localzone`, `metrics`, `qlog`, `resolver`, `zonefile` — roughly 230 -tests — and `api` joined the list with 78 cases of its own. +| Package | top-level tests | notes | +|---|---|---| +| `internal/acl` | 26 | group compilation, matching, first-group-wins | +| `internal/api` | 55 (156 cases) | the whole route table, three auth modes, CORS, rate limiting, SSE, settings, error branches, pinned limitations | +| `internal/app` | 25 | wiring, hooks, the API response contract, integration | +| `internal/blocklist` | 28 | four parsers, the set, the manager, the shipped example zone | +| `internal/cache` | 27 | LRU, TTL policy, serve-stale, bbolt round-trip | +| `internal/cluster` | in progress | Valkey lease, pub/sub, divergence | +| `internal/config` | 22 | Duration/Size, `${VAR}`, unknown keys, the shipped example configs | +| `internal/engine` | 26 | the pipeline end to end, plus the pins | +| `internal/localzone` | 18 | wildcards, delegation, CNAME following | +| `internal/metrics` | 10 | the metric names, which are the contract with the dashboards | +| `internal/qlog` | 18 | ring, bbolt, top domains, sampling | +| `internal/resolver` | 10 | pool, breakers, health, policies | +| `internal/server` | 36 | four listeners, UDP deadline reset, truncation, DoH limits, identity, shutdown, stats | +| `internal/zonefile` | 29 | the master-file parser and the shipped example zone | +| `web` | 0 | typecheck and `oxlint` clean (0 errors), no test runner | +| `cmd/fancy-dns` | 0 | exercised by `make smoke` and the CI smoke job instead | -`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`. +### The commands that matter -### No test suite yet +- `make check-go` — the inner loop (~1.3 s): gofmt check, vet, compile. Uses + `go-build`, not `build`, because `build` depends on `web` and shells out to npm. +- `make check` — `check-go` + the suite. What CI runs. +- `make smoke` — **new.** Boots the real binary with the console embedded on + unprivileged ports (the same `sed` rewrite CI uses), then checks `-check`, + `/api/healthz`, the embedded console, the unauthenticated 401, an + authenticated `whoami` and `/api/metrics`. Catches a broken embed, a bad + default and an auth regression in one command. +- `make web-check` — console typecheck and production build. +- `make bench` — cache and blocklist benchmarks. -`cluster`, `server`, `web`. +### Test coverage is no longer the interesting gap -`internal/engine` is now covered — 23 tests over a harness (`harness_test.go`) -that builds a wired engine with a fake upstream on loopback, a local zone, a -blocklist, a cache and a query log — and `internal/config` has a `Size` suite. -`internal/metrics` has a counter/gauge/exposition suite (9 tests) that pins the -metric names, which are the contract with the Grafana dashboards and the -ServiceMonitor. `internal/api` is covered too: every route in the table (each -asserted to reach its hook, so a new route without a case is a route nothing -tests), method/path rejection, a read-route sweep that asserts nothing mutated, -panic recovery, the base path, all three auth modes, read-only keys and -`allow_config_write: false`, loopback trust, and the hashed API keys. -`internal/server` is the one in progress. +The API **response contract** is. The console's types are hand-written and +`apiFetch` ends in `JSON.parse(text) as T`, so nothing in the toolchain compared +them with the Go structs; a field that lost its `json` tag, or a response a +struct never had, showed up as an empty table in a browser and nowhere else. +`internal/app` now has contract tests that assert the exact JSON key set of +every admin endpoint — see the fix list. -## Fixed in the last round (the engine and `config.Size` work) +## Fixed this round -- **`config.ParseSize` ignored the unit multiplier entirely.** `scaleSize` took - the number and returned `Size(n)`; `mult` never entered the arithmetic, so - `512MiB` parsed as **512 bytes** and `1GiB` as 1 byte. Every memory budget - written in units — `memory.limit`, `memory.reserve`, `memory.cache_max_bytes`, - the cache byte budget in the app wiring — was off by a factor of the unit, - and `EffectiveLimit`'s 64 MiB floor then quietly replaced the operator's - number. The memory envelope was inert for anyone who wrote a unit, which is - everyone: a bare number is bytes. The fix is one multiplication; - `size_test.go` now pins `1.5GiB`, `0.5KiB`, the unit shadowing cases (`MiB` - must not match `M`), the out-of-range error and the truncating rounding. -- **A cache miss and a stale hit moved only the Prometheus counter.** - `metrics.RecordMiss` and `metrics.RecordStaleHit` bumped the collector and - not the atomics that `Totals` reads, so the Cache page's "misses" and "stale - hits" read 0 for the life of the process while the Grafana panel was right — - exactly the disagreement `Record`'s doc comment says cannot happen. -- **The uncached path never truncated.** `finalize` — the EDNS0/UDP-size step - that turns an oversized answer into a question-only TC reply — was called - only on the *cache hit* path. A multi-kilobyte answer from upstream, from the - local zone or from a sinkhole left here as a datagram no 512-byte UDP client - can receive, and the client silently dropped it. All four reply paths now - finalize. -- **The serve-stale prefetch gate was not single-flight.** - `LRU.prefetchGate` was `make(chan struct{}, 64)`, so a burst of stale hits on - one hot name launched up to 64 duplicate upstream exchanges instead of the - one its own doc comment promised. It is a per-key latch now (a - `map[Key]struct{}` under a mutex): distinct stale names still refresh in - parallel, one name does not. `TryPrefetch`/`ReleasePrefetch` take the key. -- **The upstream UDP client read buffer was hard-coded to 1232** while the - engine advertises `dns.max_udp_size` upstream. An operator who raised - `max_udp_size` above 1232 got a SERVFAIL for *every* answer over 1232 bytes: - the datagram was read truncated and failed to unpack with "dns: overflowing - header size". `resolver.NewPoolWithUDPSize` takes the advertised size and the - app passes `cfg.DNS.MaxUDPSize`; `NewPool` keeps the 1232 default. +- **Every query from a client that asks for EDNS0 NSID got SERVFAIL.** + `dns.EDNS0_NSID.Nsid` is a *hex string* in miekg/dns v1.1.73 — `pack` + hex-decodes it, `unpack` hex-encodes it — and the engine put the literal + `"fancy-dns"` in it. `hex.DecodeString("fancy-dns")` fails, so the forwarded + upstream query could not be built, the exchange failed, and the client got + SERVFAIL. A one-line bug with total blast radius on a documented feature. + Fixed with `hex.EncodeToString`, and pinned from both sides: + `TestClientNSIDIsAnswered` (a client asking for NSID gets a real answer, the + option we forward upstream decodes to our own name, and `dns.hide_identity` + drops it) and `TestPinnedEDNS0NSIDIsHex` (the library contract, so a future + bump that changes `Nsid` to raw bytes fails here with an explanation instead + of silently breaking the feature). +- **The login rate limiter was applied to every route except the one it exists + for.** `allowRequest` is the only reader of `limitState.lockedTill` and it ran + behind `authed`, while the login route was registered through `public`. So N + bad API keys gave N × 401 and never a 429, and the lockout instead locked the + caller out of the *rest* of the API — brute-force protection is the one thing + the auth surface most needs, and it was the one thing that did not work. There + is now a `limited()` wrapper (the limiter only, no auth) on the login route; + `allowRequest` stays the single reader of the lockout state. Pinned by + `TestLoginBruteForceEventuallyHitsTheRateLimiter`, + `TestLoginLockoutExpiresWithTheClock`, `TestLoginLockoutIsPerIP`, + `TestLoginLockoutDisabledWithoutFailures` and the `TestAuthedRouteRateLimit*` + family, so both routes stay honest. +- **A successful login reset the attacker's request budget.** + `clearLoginFailures` deleted the whole per-IP `limitState`, which also reset + the per-minute window, so one valid key in the middle of a brute-force burst + handed back a fresh budget. It now clears only `failures` and `lockedTill`, + and `TestSuccessfulLoginDoesNotResetTheRequestWindow` / + `TestSuccessfulLoginClearsTheFailureCounter` pin both halves. +- **DoH GET had no size cap.** The POST path is capped by `readCapped` at + `dns.MaxMsgSize`; the GET path handed `?dns=` straight to a base64 decode and an + `Unpack`, so the only limit was net/http's `MaxHeaderBytes` — roughly a + megabyte of attacker-chosen base64 decoded per request, unauthenticated, + against a 64 KiB cap on the neighbouring path. A DNS message cannot exceed + 65535 bytes, so the cap is `base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize)` + and cannot refuse a conforming client. Pinned by + `TestDoHGETQueryStringOverTheCapIsRefused` (413) and + `TestDoHServesALargeButLegalQuery` (a message just under the wire limit is + still served, so the cap cannot be tightened carelessly). +- **A clean `Stop` sometimes returned "use of closed network connection".** The + DoH listener has two owners: `Stop`'s close list, and `http.Server.Shutdown`, + which tracks the same `net.Listener` because `serveDoH` hands it to + `srv.Serve`. Whichever lost that race made an orderly shutdown look like a + failed one. Closing an already-closed listener is not a failure, so `Stop` now + ignores `net.ErrClosed` and only reports a real close error. It reproduced 3 + times in 3 runs with DoH enabled and never with it disabled, which is what + made it findable; `TestStopIsIdempotent` is the pin. +- **A per-transport `dropped` counter did not exist.** The DoH drop fix above + was invisible in the UI: `HandlerStats.Dropped` is an aggregate that cannot + say which transport dropped, and `TransportStats` had no `Dropped` field at + all, even though all four transports bump the counter. Added, with the + reasoning in the field's comment. +- **A DoH drop was not counted as a drop.** + `dropped` when `res.Drop`; the DoH path only reached `classify`, which maps + `drop` to `errors`, so `HandlerStats.Dropped` under-counted DoH. One line, in + the same position as the other two. +- **Both shipped zone files matched nothing.** `blocklist.local_zones` and the + `rpz`-format list source are read with the origin taken from the file's base + name (`zonefile.OriginFromPath`: `local.example.zone` → `local.`), so the + relative owner names in the examples were being qualified with that — a + `*.doubleclick.net` rule blocked `*.doubleclick.net.local.`, and the local + zone mixed relative owners with absolute ones. An operator copying either file + got a server that answered nothing and blocked nothing. Both examples now + spell every name out in full, and + `TestExampleLocalZoneIsOriginIndependent` (zonefile) plus + `TestExampleRPZZoneBlocksWhatItSays` (blocklist, through the real + `parseRPZFiles` path) fail if they are "tidied up" back to relative names. +- **The shipped configs were never loaded by anything.** + `deploy/config.example.yaml` and the ConfigMap in `deploy/k8s/00-config.yaml` + are now loaded through `config.Load` (which ends in `Validate`) and + spot-checked, including that the k8s `${FANCYDNS_API_KEY}` still expands and + still *fails* when the Secret is missing, and that the memory envelope matches + the StatefulSet's 1Gi limit. +- **The admin API's response shapes had drifted from the console's types** — + verified against a running binary, not inferred. `blocklist.Source` has no + `json` tags at all, so the Sources table rendered blank rows and its + per-row toggle posted `name: undefined`; `acl.GroupSummary` is flat where the + console expects a nested `match`, so every group card read "matches nothing"; + and **both write endpoints answered 400 to the payload the console actually + sends** (`json: unknown field "ptr_suffixes"` on `PUT /api/clients`, + `unknown field "health"` on `PUT /api/upstreams`). The Go side is being fixed + with contract tests that assert the exact key set of every endpoint; the + console side is the next piece of work. +- **The GitHub Actions image job could never tag an image.** It used + `${{ gitea.sha }}` in a workflow under `.github/`, and the `gitea` context + only exists on Gitea; the image is also published by + `.gitea/workflows/build.yaml`, which is the pipeline with registry access. It + now tags with `github.sha` and only logs in and pushes when this repository + actually has a registry token, so a GitHub runner builds and stops instead of + failing. +- **`api.constantTimeEqual` had no callers**, and the `authenticator` doc + comment described a constant-time "API key scan" that does not exist. The real + design is better than the comment (API keys are compared as SHA-256 digests + through a hash-keyed map, so the plaintext is never stored) — the dead + function is gone and the comment now describes the code. +- **`maxGETResponse` was dead code** whose comment claimed a guarantee the + handler did not provide. Deleted; the DoH response is written as packed. -### Fixed recently and worth not re-breaking +## Pinned, not fixed (each one has a test that says so out loud) -- **Every relative RDATA name in a master file pointed at the DNS root.** The - qualification pass ran *after* `dns.NewRR`, and the library resolves RDATA - names against the root, so `www CNAME host` became `CNAME host.` — the alias - pointed at the root, and so did every relative NS, MX, PTR, SRV, NAPTR and SOA - target in every zone file. It is now a token-level pass driven by a table of - which RDATA positions hold a name; dotted names stay absolute, so the RPZ - targets `.` and `rpz-drop.` and the null MX/SRV targets are untouched. -- **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 manifest 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. -- **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, so the - engine's per-query recompile lost the "everything" case. -- **`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`**, because the - parallel path was only taken when exactly one upstream was chosen. -- **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, wired to - `debug.SetMemoryLimit`. The reserve covers what the Go heap does not account - for: goroutine stacks, runtime metadata, and the bbolt mmap. -- **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. -- **`$TTL` was ignored** by the zone parser; every record got the SOA minimum. -- **`TestRebindUnderConcurrentQueries` was a flake** (2 runs in 10) — it asserted - a reader had to observe two specific addresses, which is a claim about what a - goroutine happened to see. It now pins the copy-on-write invariant itself: - every observed snapshot must equal one of the published lists. -- **`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. -- **`serveUDP` called `net.ResolveUDPAddr` per packet**; the peer already is a - `*net.UDPAddr`. +The standing decision for this round was: **pin, do not change**. Every item +below is behaviour someone may want to change deliberately; each has a test +that fails when it changes, and says what to do when it does. -## New findings from the `api` suite, unfixed - -- **The login rate limiter never applies to the login endpoint.** `handleLogin` - is registered through `public`, which bypasses `authed` and therefore - `allowRequest` — the only reader of `lockedTill`, and `handleLogin` the only - writer of `failures`. So `api.rate_limit.per_minute`, documented as "requests - per minute per IP **for the login endpoint**", applies to every *other* route - instead: posting 100 bad keys to `/api/auth/login` returns 100 × 401 and never - a 429, and the lockout only ever locks the caller out of the rest of the API. - Brute-force protection is the one thing the auth surface most needs. The suite - asserts nothing about this, so the fix is a free choice: register login - through the limiter, or move the lockout check into the login handler. +- **Unknown config keys are silently ignored** (a settled decision, see the + table above). `TestLoadIgnoresUnknownKeys` in `internal/config` also pins the + asymmetry that makes this surprising: a typo is invisible, while a known key + with a bad *type* is a startup failure. +- **`metrics.OutcomeLocal` has no producer.** The engine reports a local-zone + answer as `qlog.ActionAllow`, which `outcomeFor` maps to `OutcomeUpstream`, so + every locally answered query is counted as an upstream one and the + `outcome="local"` series is never published — the dashboard's local column is + permanently empty. `TestPinnedLocalAnswersAreCountedAsUpstream` asserts the + upstream counter moves by one and the local series stays absent. Fixing it + moves published counters, so it wants a decision and a dashboard note. +- **An RPZ record does not cover the name it names.** `rpzRule` gives a bare + owner name the subdomain form (`*.name`), and a subdomain-form rule matches + the name's children but not the name itself, so `analytics.example.org.` in a + policy zone blocks `cdn.analytics.example.org.` and leaves + `analytics.example.org.` resolving normally. RPZ semantics say the record + applies to the domain and everything under it, so `**.name` is what belongs + here — a one-character change in `rpzRule` that alters which queries get + blocked. Verified end to end against a running binary: the subdomain is + NXDOMAIN, the apex is not. Pinned by `TestExampleRPZZoneApexIsNotCovered`, + which also shows the same gap on the allow side (an `rpz-passthru.` record + for a domain does not allowlist the domain itself). - **Auth configuration is frozen at `New`.** `api.base_path`, `api.trusted_proxies` and the authenticator (`auth.mode`, `api_keys`) are - captured once in `New`; a reload re-reads CORS only. Writing a new API key - through the settings endpoint therefore answers `ok` while the running server - keeps the old key, and a `trusted_proxies` change needs a restart. A config - write endpoint that cannot apply its own auth change is an operational trap. - Pin the current behaviour with a test before changing it. -- **`api.constantTimeEqual` has no callers**, and the `authenticator` doc comment - describes a constant-time "API key scan" that does not exist. The real design - is better than the comment: API keys are compared as SHA-256 digests through a - hash-keyed map and the key itself is never stored, which satisfies the - AGENTS.md invariant. The comment is wrong, not the code; delete the dead - function and fix the comment. - -### Open decisions - -- **Unknown config keys are silently ignored.** Writing `block:` where the struct - wants `blocklist:` produced a plausible-looking server, and two test fixtures - were caught by this rather than by what they were testing. `KnownFields(true)` - would reject it, but that is a behaviour change for anyone carrying extra keys. + captured once; a reload re-reads CORS only. Writing a new API key through + `PUT /api/settings` answers `ok` while the running server keeps the old key. + `TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart` and + `TestCORSOriginsAreReadAtHandlerBuild` hold this in place until someone decides + whether a reload should re-apply it. - **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. -- **A character-string longer than 255 bytes cannot be packed by this version - of `miekg/dns` (v1.1.73).** `TXT.len()` reports a >255-byte string as zero - length and `EDNS0_NSID.len()` is likewise wrong, so `Msg.Pack` fails with - "dns: overflow packing opt" — or, on the server write path, silently fails - and the query times out. Two consequences: a fixture that wants a - multi-kilobyte answer has to build many short strings (the engine harness - uses 15 x 200), and a **real DoT/DoH/UDP client that sends an NSID option - gets a SERVFAIL**, because the engine forwards its own NSID for such a query - and the message will not pack. Not fixed: it needs a library bump or a - deliberate decision to answer NSID locally rather than forward it. -- **`"1Gi"` is not a valid size** — only `GiB`, `G`, `GB`. The parser is - deliberately strict, but k8s spells it `1Gi` and the k8s manifests are the - likeliest place an operator writes a memory budget. Left as is and pinned by - a test; worth a decision. -- **`metrics.OutcomeLocal` has no producer.** The engine reports a local-zone - answer as `ActionAllow`, which `finish` maps to the *upstream* outcome, so - locally answered queries inflate the upstream counter and the dashboard's - local column is always empty. Not changed: fixing it moves published - counters, so it wants a decision and a changelog note. + client lands in the Ingress's ACL group: fail-closed if that group is + restrictive, fail-open if it is permissive. + `TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader` pins it, with the + reason honouring `X-Forwarded-For` without `trusted_proxies` would break the + ACL and rate-limit invariants. +- **`api.cors_origins: ["*"]` echoes the request's `Origin` and sets + `Access-Control-Allow-Credentials: true`.** Deliberate (a literal `*` is + ignored by browsers when credentials are involved), but it does mean any + website can drive the admin API with whatever the browser has cached for that + host. The preflight also answers 204 for any origin and any path. + `TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight` pin all of it. +- **A partial bind failure is invisible.** `serveDoT`/`serveDoH` record a failed + bind in an error list only, so `ListenerInfo.Err` is never set and the admin + API cannot show which address failed. + `TestStartReportsAPartialFailureButServes` pins that `Start` returns nil + whenever any listener came up. +- **A question-less message is answered on UDP and kills the connection on + TCP/DoT.** `TestUDPQuestionlessMessageReachesTheEngine` pins the UDP half (the + engine turns it into a FORMERR); the stream path returns instead. Probably + right for both, but it is an asymmetry worth knowing about. +- **A UDP query in flight at `Stop` can be lost.** The stream paths register + with `s.wait`, so `Stop` waits for them; `servePacket` does not, so a UDP + query already inside the pipeline when the listener closes may never be + answered. That is arguable — UDP has no connection to hang up on and the + client will retry — but the asymmetry with TCP/DoT is not pinned anywhere, and + it should be a decision rather than an accident. +- **`doh.sent` is never bumped.** The DoH handler writes its answer through + `net/http`, so the `sent` counter is only meaningful on UDP/TCP/DoT, and the + console's transport table shows `sent: 0` for DoH. Pinned as current + behaviour by `TestCountersFollowTheEngineOutcome` with a comment saying why. +- **`api.metrics.path` is ignored** + always `${base_path}/metrics`); `POST /api/blocklists/refresh` returned + `{"names": null}`; an empty `config_yaml` in the settings form is not valid (it + decodes onto `Default()`, which enables a blocklist with no sources); `"1Gi"` + is not a valid size — only `GiB`, `G`, `GB` — even though that is how k8s + spells it. ## Continuation plan, in order -1. ~~**`internal/engine` suite**~~ — **done**, 23 tests. The original ask was - the largest uncovered surface and the one every - packet touches. Pin the pipeline order end to end: ACL deny before anything - else, local zone before blocklist, blocklist before cache, a blocklist hit - served and cached without touching the upstream, the concurrency and - rate-limit refusals, serve-stale with its single background refresh, - truncation against a 512-byte client buffer, and that the metrics and qlog - hooks fire exactly once per query and not at all for a query rejected before - the pipeline reached them. Reuse the harness in - `internal/app/integration_test.go`. -2. ~~**`internal/api` suite**~~ — **done, partly** (78 cases): the route table, - the three auth modes, the hashed-key invariant, `trusted_proxies` and - loopback trust, read-only keys, `allow_config_write: false`, panic recovery. - **Not covered**, and the harness already carries what they need - (`fake.stream`/`unsub` for SSE, `fake.fail` for error paths, `harness.srv.now` - as an advanceable clock): CORS, the rate limiter, the SSE - open/unsubscribe/no-leak case, `/metrics` (enabled/protected/501), and the - settings PUT/validate/reload happy paths. -3. **`internal/server` suite** — in progress. All four listeners behind one handler, - per-listener config, the UDP read-deadline reset, shutdown ordering. -4. **`internal/metrics` + `internal/cluster`** — the collectors are **done** (9 - tests, the metric names pinned as the contract with the dashboards); what is - left is `cluster`: the rolling rings, the Valkey lease, pub/sub sync, - divergence detection. `miniredis` is the obvious test server. -5. ~~Tests for `config.Size`~~ — **done**, and the unit-multiplier bug it was - supposed to catch was real. -6. ~~Wire `memory:` into `deploy/k8s/00-config.yaml`, matched to the - StatefulSet's 1Gi limit.~~ — **done**: `1GiB` limit, `256MiB` reserve, - `384MiB` cache budget. -7. Decide on `KnownFields(true)`. -8. Re-run the console end to end; the API surface has changed several times. +1. **`internal/cluster` suite** — in progress. The rolling heartbeat, the Valkey + lease and leader handoff, pub/sub config sync, divergence detection and + reconnect, against `miniredis`. +2. **The API response contract** — in progress on the Go side + (`internal/app` contract tests + the fixes listed above). +3. **The console side of that contract** — the next piece of work, and it should + be done against the canonical key list from step 2: the `qtype` fields that + are numbers on the wire but strings in TypeScript (the server also sends + `qtype_name`), `blocked_pct` arriving as 0..100 from the dashboard and 0..1 + over SSE, the `whoami.can_write` / `read_only` flags that are fetched and + never used (a read-only key still shows every Save control), a `latency` + block the dashboard never receives, the cache "Lookups"/"Last seen" columns + reading `count`/`last_seen` where the API sends `hits`, and the refresh toast + that says "0 sources queued" because the API returned null. +4. **The remaining server tests** — in progress: shutdown ordering (in-flight + queries, idempotent `Stop`, a cancelled context, the port actually being + released), the stats counters, and a table for the pure helpers. +5. **Decide the open questions** in the pinned list above, one at a time, each + with a changelog note: the RPZ apex, `OutcomeLocal`, the frozen auth config, + the DoH client identity, CORS wildcards. +6. **Stop hand-writing the console's types.** The contract tests make the Go + side authoritative; the cheapest durable answer is generating + `web/src/lib/types.ts` from the Go structs (or an OpenAPI document emitted by + the server) so `apiFetch` is typed against something real instead of + `JSON.parse(text) as T`. ## Design notes worth preserving @@ -292,10 +329,11 @@ panic recovery, the base path, all three auth modes, read-only keys and 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`. + 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. + rename one, grep the frontend too — and now also expect `internal/app`'s + contract test to fail, which is the point of it. - **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 @@ -307,9 +345,13 @@ panic recovery, the base path, all three auth modes, read-only keys and 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. + needs them to re-add a rule unchanged (the domains list ships `rule` for + exactly this). - **pprof is loopback-only by construction**, not by policy, so no config flag can turn it into a network-reachable unauthenticated endpoint. +- **A master file's origin is its base name**, so a relative owner name in a + zone is qualified with the file's name, not with the zone you meant. Absolute + names are the only spelling that survives a rename. ## Test-hygiene traps @@ -318,14 +360,76 @@ panic recovery, the base path, all three auth modes, read-only keys and - 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. + "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. - `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. -- **`dns.Msg` with a character-string over 255 bytes does not pack** in - miekg/dns v1.1.73 (`TXT` of 4 KiB, any `EDNS0_NSID`). The failure is silent on - a server write path, so it presents as a client-side timeout; build a big - answer out of many strings under 255 bytes instead. + Restore it or the directory disappears from git. `//go:embed all:dist` still + builds while the directory is empty, so a console rebuild in the middle of + someone else's `go build` is not fatal. +- **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 the failure is silent and presents + as a client timeout, so build a multi-kilobyte answer out of many short + strings 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.** A plain + value such as `"fancy-dns"` fails with `dns: overflow packing opt`, and a long + NSID packs fine (unlike `TXT`). Always hex-encode on the way in. - `gofmt -l internal/` is the fastest way to see that a file does not parse. +- The console has **no test runner at all** — only `tsc -b --noEmit` and + `oxlint`. A field-name drift between the API and the console is invisible to + both, which is why the Go side now has contract tests. + +## Fixed earlier and worth not re-breaking + +A running list, kept because each of these was invisible until it was found: + +- `config.ParseSize` ignored the unit multiplier entirely (`512MiB` parsed as + 512 bytes), so every memory budget in the tree was off by a factor of the + unit. `size_test.go` pins the units, the shadowing cases, the range and the + truncating rounding. +- A cache miss and a stale hit moved only the Prometheus counter and not the + atomics the console's `Totals` reads, so the Cache page read zero for the + life of the process. +- `finalize` — the EDNS0/UDP-size step that turns an oversized answer into a + question-only TC reply — ran only on the cache-hit path, so a multi-kilobyte + answer from upstream, from the local zone or from a sinkhole was left as a + datagram a 512-byte UDP client silently dropped. +- The serve-stale prefetch gate was a 64-slot channel, so a burst of stale hits + on one hot name launched up to 64 duplicate upstream exchanges instead of the + one its own comment promised. It is a per-key latch now. +- The upstream UDP client read buffer was hard-coded to 1232 while the engine + advertises `dns.max_udp_size`, so an operator who raised it got SERVFAIL for + every answer over 1232 bytes. +- Every relative RDATA name in a master file pointed at the DNS root: the + qualification pass ran *after* `dns.NewRR`, so `www CNAME host` became + `CNAME host.`. It is now a token-level pass driven by a table of which RDATA + positions hold a name. +- The cache snapshot never round-tripped: `Load` decoded the record header out of + the *key's* remainder instead of the value, so every restart was a cold cache. + There is now an `encodeRecord`/`decodeRecord` pair with a version byte and a + CRC-32C trailer, and `Load` drops expired entries and rewrites the TTL to the + time actually left. +- `${VAR}` expansion never worked inside quotes — the only form anyone writes — + because the reference check sat below the quote branches in the same switch. + The admin API key was the literal string `"${FANCYDNS_API_KEY}"`. +- ACL PTR suffix matching crossed label boundaries, so a group scoped to + `example.com.` also swallowed `ample.com.`; the ACL `*` catch-all was dropped + from the rendered domain patterns; `Upstream.Success` never closed an open + breaker; `Exchange` fanned out in parallel even under `policy: failover`; the + cache byte budget was never fed to the LRU. +- **`TestRebindUnderConcurrentQueries` was a flake** (2 runs in 10) because it + asserted a reader had to observe two specific addresses. It now pins the + copy-on-write invariant itself. +- CI could not have passed (`go.mod` requires Go 1.27.1, CI and the Dockerfile + pinned 1.23), the Dockerfile clobbered the console by copying the source tree + after the bundle, and the k8s set could not have worked (`hostNetwork: true` + with `replicas: 2` and no SO_REUSEPORT). +- `$TTL` was ignored by the zone parser; every record got the SOA minimum. +- Blocklist: leading BOM stripped, `!allow` works, `SourceMeta` exists for a + rule-only source, `Pattern()` reports the bare name while `WireName()` gives + the wildcard form, RPZ inherits the zone default TTL, hosts files complain + about a missing address, adblock handles CRLF and over-long lines. +- `serveUDP` called `net.ResolveUDPAddr` per packet; the peer already is a + `*net.UDPAddr`. diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml index 414c0c9..19a08e2 100644 --- a/deploy/k8s/10-deployment.yaml +++ b/deploy/k8s/10-deployment.yaml @@ -150,8 +150,9 @@ spec: - name: config configMap: name: fancy-dns-config - # Local zones are ordinary master files; ship them in a ConfigMap or - # manage them through the admin API (`/api/zones`), which writes into + # Local zones are ordinary master files; ship them in a ConfigMap or + # edit them in the mounted volume. There is no zone API — the admin API + # drives blocklists, the cache and the query log, not the zone files. # the data volume. Optional because the glob in the config matches # nothing cleanly. - name: zones diff --git a/deploy/local.example.zone b/deploy/local.example.zone index e014a63..6cf38ff 100644 --- a/deploy/local.example.zone +++ b/deploy/local.example.zone @@ -1,24 +1,33 @@ ; Local authoritative data. fancy-dns answers for these names itself, which ; removes the need for a second nameserver for a handful of records. +; +; Every name here is fully qualified on purpose. A master file is loaded with +; an origin, and for a file under `blocklist.local_zones` that origin is the +; file's base name (zonefile.OriginFromPath): `local.example.zone` gives the +; origin "local.". A relative name would be qualified with THAT, so "ns1" +; here would become ns1.local. and not ns1.lan. — a zone that looks right in +; the editor and answers nothing. Absolute names are the same in every file, +; whatever it is called. $TTL 300 -@ SOA ns1.lan. hostmaster.lan. 2024010101 3600 600 604800 300 -@ NS ns1.lan. -@ NS ns2.lan. +lan. SOA ns1.lan. hostmaster.lan. 2024010101 3600 600 604800 300 +lan. NS ns1.lan. +lan. NS ns2.lan. -ns1 A 192.168.1.2 -ns2 A 192.168.1.3 +ns1.lan. A 192.168.1.2 +ns2.lan. A 192.168.1.3 -router A 192.168.1.1 -nas A 192.168.1.10 -nas AAAA fd00::10 +router.lan. A 192.168.1.1 +nas.lan. A 192.168.1.10 +nas.lan. AAAA fd00::10 ; A wildcard covers one label: printer.lan., host.lan., ... -*.lan A 192.168.1.50 +*.lan. A 192.168.1.50 ; A wildcard deeper down. -*.internal.lan A 10.0.0.50 +*.internal.lan. A 10.0.0.50 -; Services. +; Services. These were already absolute; keeping every record absolute means +; the file reads the same as it behaves. _ntp._udp.lan. SRV 0 0 123 ns1.lan. _ldap._tcp.lan. SRV 10 0 389 nas.lan. _dmarc._domain.lan. TXT "v=DMARC1; p=reject; rua=mailto:dmarc.lan" diff --git a/deploy/zones/local.rpz.zone.example b/deploy/zones/local.rpz.zone.example index 1c25690..d2d64fd 100644 --- a/deploy/zones/local.rpz.zone.example +++ b/deploy/zones/local.rpz.zone.example @@ -1,23 +1,31 @@ ; Example RPZ-style policy zone. ; ; Drop everything from these services, for this host and its subdomains. +; +; As in local.example.zone, every owner name is fully qualified. A policy zone +; is read with the origin taken from the file name (internal/blocklist prepends +; `$ORIGIN` per file), so a relative `*.doubleclick.net` in a file called +; local.rpz.zone.example becomes *.doubleclick.net.local. — a rule that blocks +; nothing you care about. Absolute owner names are the only spelling that is +; independent of what the file is called; TestExampleRPZZoneBlocksWhatItSays +; in internal/blocklist pins that the example blocks what its comments claim. $TTL 60 -@ SOA localhost. hostmaster.localhost. 1 3600 600 604800 60 -@ NS localhost. +localhost. SOA localhost. hostmaster.localhost. 1 3600 600 604800 60 +localhost. NS localhost. ; *.doubleclick.net CNAME . -> NXDOMAIN -*.doubleclick.net CNAME . -*.ads.example.com CNAME . -*.tracker.example.net CNAME . -analytics.example.org CNAME . +*.doubleclick.net. CNAME . +*.ads.example.com. CNAME . +*.tracker.example.net. CNAME . +analytics.example.org. CNAME . ; CNAME rpz-drop. -> answer with silence -*.noisy-cdn.example CNAME rpz-drop. +*.noisy-cdn.example. CNAME rpz-drop. ; CNAME rpz-passthru. -> ignore blocklists for this name -metrics.internal. CNAME rpz-passthru. +metrics.internal. CNAME rpz-passthru. ; A/AAAA records in a policy zone are returned verbatim, which is the cleanest ; way to point a blocked name at a local landing page. -blocked.example A 192.0.2.10 - AAAA 2001:db8::10 +blocked.example. A 192.0.2.10 + AAAA 2001:db8::10 diff --git a/go.mod b/go.mod index c83f328..3fd8663 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module git.k8s-git-k6n.net/mats/fancy-dns go 1.27.1 require ( + github.com/alicebob/miniredis/v2 v2.39.0 github.com/miekg/dns v1.1.73 github.com/prometheus/client_golang v1.24.1 + github.com/redis/go-redis/v9 v9.22.0 go.etcd.io/bbolt v1.5.0 golang.org/x/net v0.59.0 gopkg.in/yaml.v3 v3.0.1 @@ -17,7 +19,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect - github.com/redis/go-redis/v9 v9.22.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/go.sum b/go.sum index a2deba2..7b74679 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g= +github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -8,6 +14,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= @@ -28,6 +36,10 @@ github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/internal/api/api.go b/internal/api/api.go index 71bf230..3eaa3c3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,7 +9,6 @@ package api import ( "context" - "crypto/subtle" "encoding/json" "errors" "fmt" @@ -241,7 +240,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux { http.Error(w, "metrics not available", http.StatusNotImplemented) }))) } - mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin)) + mux.Handle("POST "+base+"auth/login", s.limited(s.handleLogin)) mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false)) // Everything below requires a principal. @@ -320,6 +319,22 @@ func (s *Server) cors(next http.Handler) http.Handler { // public marks a handler as reachable without a principal. func (s *Server) public(h http.HandlerFunc) http.Handler { return h } +// limited applies the per-IP rate limit to a handler that is reachable without +// a principal. The login endpoint is the reason the limit exists: guessing +// API keys is bounded by nothing but the attacker's patience, so the lockout +// the login handler records has to be able to stop the *next* attempt. It did +// not, because the limiter ran behind authed() and login is public, so N bad +// keys gave N 401s and the lockout only locked the caller out of the rest of +// the API. +func (s *Server) limited(h http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.allowRequest(w, r) { + return + } + h(w, r) + }) +} + // cfg is a small accessor so handlers read the current config once. // authed wraps a handler with authentication and, for mutations, a write @@ -503,8 +518,17 @@ func (s *Server) recordLoginFailure(ip string) { func (s *Server) clearLoginFailures(ip string) { s.limMu.Lock() - delete(s.limits, ip) - s.limMu.Unlock() + defer s.limMu.Unlock() + st, ok := s.limits[ip] + if !ok { + return + } + // Only the auth-failure state is reset. Deleting the whole entry would + // also reset the per-minute request window, so a single successful login + // in the middle of a burst would hand the client a fresh budget — which + // is exactly what an attacker holding one valid key wants. + st.failures = 0 + st.lockedTill = time.Time{} } // protectMetrics gates /metrics behind an API key when configured. @@ -597,10 +621,6 @@ func queryTime(r *http.Request, key string) time.Time { return time.Time{} } -func constantTimeEqual(a, b string) bool { - return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 -} - func isFileWritable(path string) bool { if path == "" { return false diff --git a/internal/api/auth.go b/internal/api/auth.go index 61ba680..d4af2ae 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -17,13 +17,14 @@ import ( // // Two details matter for security here: // -// - Every comparison is constant-time, including the API key scan. A -// byte-at-a-time comparison leaks a valid key prefix to anyone who can -// measure response latency, which is a real attack on a network-exposed -// admin API. -// - Keys are compared by hash, not by string, so the stored form is not a -// raw key and a timing difference between a long and short key cannot -// reveal length. +// - Credentials are never compared in plaintext. An API key is hashed with +// SHA-256 and looked up in a map keyed on that hash, so the long-lived +// structure holds no key that could be read back out of a core dump, and a +// lookup is a hash-map hit whose cost does not depend on how much of the +// key the caller guessed. There is no byte-at-a-time scan: the comment that +// used to describe one described a function with no callers. +// - The two fields that are compared directly — the basic username and the +// hashed password — go through subtle.ConstantTimeCompare. type authenticator struct { mode string // readOnly maps key hash -> key label. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 4798fbf..bcc32d8 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -340,13 +340,6 @@ func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) { t.Errorf("stored form %q is not a hex sha256", stored) } } - // constantTimeEqual is the comparison helper: equal only when equal. - if !constantTimeEqual("abc", "abc") { - t.Error("constantTimeEqual(abc, abc) = false") - } - if constantTimeEqual("abc", "abd") || constantTimeEqual("abc", "ab") || constantTimeEqual("", "x") { - t.Error("constantTimeEqual accepted a mismatch") - } } func mapKeys(m map[string]string) []string { diff --git a/internal/api/cors_test.go b/internal/api/cors_test.go new file mode 100644 index 0000000..8714c25 --- /dev/null +++ b/internal/api/cors_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// corsHarness starts a harness with the given cors_origins list. The origins +// are read once, when Handler() builds the wrapper, so a config edit afterwards +// does not move them — which is the same freezing the settings route has and is +// pinned by TestCORSOriginsAreReadAtHandlerBuild. +func corsHarness(t *testing.T, origins ...string) *harness { + t.Helper() + return newHarness(t, func(c *config.Config) { c.API.CORSOrigins = origins }) +} + +// TestCORSAllowedOrigin checks a configured origin is echoed back with the +// header set a credentialed fetch needs, and that Vary: Origin is present. Vary +// is the part that matters operationally: without it a shared cache will serve +// one origin's allow-origin header to another. +func TestCORSAllowedOrigin(t *testing.T) { + h := corsHarness(t, "https://console.example", "http://localhost:5173") + cases := []struct { + name string + origin string + }{ + {"https origin", "https://console.example"}, + {"plain http origin", "http://localhost:5173"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := h.do(http.MethodGet, "/api/whoami", "", + "X-API-Key: rw-key", + "Origin: "+tc.origin) + if res.status != http.StatusOK { + t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) + } + if got := res.header.Get("Access-Control-Allow-Origin"); got != tc.origin { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, tc.origin) + } + if got := res.header.Get("Vary"); got != "Origin" { + t.Errorf("Vary = %q, want \"Origin\"", got) + } + if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got) + } + if got := res.header.Get("Access-Control-Allow-Methods"); got == "" { + t.Error("no Access-Control-Allow-Methods advertised") + } + if got := res.header.Get("Access-Control-Allow-Headers"); got == "" { + t.Error("no Access-Control-Allow-Headers advertised") + } + }) + } + // The allowed methods/header lists must actually name what the console + // sends, or a preflight for a PUT fails and the settings editor is + // unusable from a browser. + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example") + if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") || !strings.Contains(got, "DELETE") { + t.Errorf("Access-Control-Allow-Methods = %q, want PUT and DELETE in it", got) + } + if got := res.header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-API-Key") || !strings.Contains(got, "Authorization") { + t.Errorf("Access-Control-Allow-Headers = %q, want X-API-Key and Authorization in it", got) + } +} + +// TestCORSDisallowedOrigin checks an origin that is not configured gets no CORS +// headers at all. The request still runs — CORS is enforced by the browser, not +// by refusing the request — but the browser must not be told the origin is +// allowed. +func TestCORSDisallowedOrigin(t *testing.T) { + h := corsHarness(t, "https://console.example") + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://evil.example") + if res.status != http.StatusOK { + t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) + } + for _, hdr := range []string{ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + } { + if got := res.header.Get(hdr); got != "" { + t.Errorf("%s = %q for a disallowed origin, want it absent", hdr, got) + } + } + // Vary is only added for an allowed origin, which is a caching footgun + // this suite pins: a shared cache that stored the unvaried response could + // hand the console's allow-origin to the other origin. + if got := res.header.Get("Vary"); got != "" { + t.Errorf("Vary = %q for a disallowed origin, want it absent", got) + } +} + +// TestCORSWildcardEchoesTheOrigin checks "*" is honoured and, as the code +// comment says, echoed per request rather than answered as "*" — a literal +// wildcard is not accepted by a credentialed fetch. +func TestCORSWildcardEchoesTheOrigin(t *testing.T) { + h := corsHarness(t, "*") + for _, origin := range []string{"https://a.example", "https://b.example", "http://localhost:5173"} { + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: "+origin) + if got := res.header.Get("Access-Control-Allow-Origin"); got != origin { + t.Errorf("Access-Control-Allow-Origin for %s = %q, want the origin echoed", origin, got) + } + } + // The wildcard is the risky setting, so pin that credentials are still + // advertised for it: the code does NOT distinguish wildcard from an exact + // origin, which means "*" plus credentials is only safe because the + // wildcard is discouraged in the config comment. If this ever changes, the + // browser semantics change with it, so it is pinned rather than assumed. + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://a.example") + if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("wildcard: Access-Control-Allow-Credentials = %q, want \"true\" (pinned current behaviour)", got) + } +} + +// TestCORSPreflight checks OPTIONS is answered by the CORS wrapper with 204 and +// the method/header list, before authentication and before the mux — a +// preflight carries no API key, so a route that answered it would answer 401. +func TestCORSPreflight(t *testing.T) { + h := corsHarness(t, "https://console.example") + cases := []struct { + name string + path string + origin string + wantACA bool + }{ + {"registered route", "/api/whoami", "https://console.example", true}, + // The preflight is answered for any origin, allowed or not: the + // browser compares Access-Control-Allow-Origin itself, and a 403 + // answer to a preflight is not a thing the browser can interpret. + {"disallowed origin", "/api/whoami", "https://evil.example", false}, + // Not a registered route at all — the wrapper runs before the mux. + {"unknown route", "/api/nope", "https://console.example", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := h.do(http.MethodOptions, tc.path, "", + "Origin: "+tc.origin, + "Access-Control-Request-Method: PUT", + "Access-Control-Request-Headers: x-api-key") + if res.status != http.StatusNoContent { + t.Fatalf("OPTIONS %s = %d, want 204: %s", tc.path, res.status, res.body) + } + if len(res.body) != 0 { + t.Errorf("preflight returned a body: %q", res.body) + } + acao := res.header.Get("Access-Control-Allow-Origin") + if tc.wantACA && acao != tc.origin { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", acao, tc.origin) + } + if !tc.wantACA && acao != "" { + t.Errorf("Access-Control-Allow-Origin = %q for a disallowed origin, want it absent", acao) + } + if tc.wantACA { + if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") { + t.Errorf("Access-Control-Allow-Methods = %q, want PUT in it", got) + } + // HTTP header names are case-insensitive and the server + // advertises a fixed list, so compare case-insensitively. + if got := strings.ToLower(res.header.Get("Access-Control-Allow-Headers")); !strings.Contains(got, "x-api-key") { + t.Errorf("Access-Control-Allow-Headers = %q, want x-api-key in it", got) + } + } + }) + } +} + +// TestCORSDisabled checks a config without cors_origins is not wrapped at all: +// no headers, and OPTIONS falls through to the mux, which answers a method it +// does not serve rather than pretending a preflight succeeded. +func TestCORSDisabled(t *testing.T) { + h := newHarness(t, nil) // testConfig leaves CORSOrigins nil + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example") + if res.status != http.StatusOK { + t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body) + } + if got := res.header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q with no CORS configured, want it absent", got) + } + res = h.do(http.MethodOptions, "/api/whoami", "", "Origin: https://console.example") + if res.status != http.StatusMethodNotAllowed { + t.Errorf("OPTIONS with no CORS configured = %d, want 405 from the mux: %s", res.status, res.body) + } +} + +// TestCORSOriginsAreReadAtHandlerBuild pins the same freezing the settings +// route has for the rest of the auth config: the allow-list is captured when +// Handler() is called, so adding an origin through the settings endpoint +// answers ok and changes nothing until a restart. Written down so that fixing +// the freezing later starts from a test that says what it is. +func TestCORSOriginsAreReadAtHandlerBuild(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://old.example"} }) + body := settingsBody(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://new.example"} }) + if res := h.do(http.MethodPut, "/api/settings", body, "X-API-Key: rw-key"); res.status != http.StatusOK { + t.Fatalf("settings put = %d: %s", res.status, res.body) + } + // The store carries the new list... + if got := h.store.Get().API.CORSOrigins; len(got) != 1 || got[0] != "https://new.example" { + t.Fatalf("store cors_origins = %v, want the written list", got) + } + // ...but the running server still answers for the origin it was built with. + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://new.example") + if got := res.header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("the written origin is already allowed (%q): the allow-list is not frozen as this test pins it", got) + } + if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://old.example"); res.header.Get("Access-Control-Allow-Origin") == "" { + t.Error("the origin the server was built with is no longer allowed") + } +} diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go new file mode 100644 index 0000000..3bd7b3f --- /dev/null +++ b/internal/api/errors_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "errors" + "net/http" + "testing" +) + +// errHook is what a component returns when it cannot do its job. +var errHook = errors.New("component is unavailable") + +// errMultiLine is what the config validator aggregates, one problem per line. +var errMultiLine = errors.New("first problem\nsecond problem") + +// setFail tells one hook to return an error, which is how the error branches of +// a handler are reached without a real component failing underneath. +func setFail(h *harness, hook string, err error) { + h.fake.mu.Lock() + h.fake.fail[hook] = err + h.fake.mu.Unlock() +} + +// TestHookErrorsAreReported walks the routes whose hook can fail and pins what +// each handler does with the error. The status codes are not uniform and that +// is deliberate in two places: a component that could not answer is a 500 (the +// operator has to know the console is lying), while a component that rejected +// the *request* is a 400, and a domain lookup that failed is a 200 carrying the +// reason, because "why is this name not blocked?" is the question the route +// exists to answer. +func TestHookErrorsAreReported(t *testing.T) { + cases := []struct { + name string + fail string + method string + path string + body string + want int + }{ + // Reads: the component could not produce an answer. + {"dashboard", "Dashboard", http.MethodGet, "/api/dashboard", "", http.StatusInternalServerError}, + {"domain list", "ListDomains", http.MethodGet, "/api/domains", "", http.StatusInternalServerError}, + {"log search", "SearchLog", http.MethodGet, "/api/logs", "", http.StatusInternalServerError}, + // A domain check that fails still answers, with the reason: 200 here + // is the point of the route. + {"domain check answers 200 with the error", "DomainCheck", http.MethodGet, "/api/domains/check?name=a.example.com", "", http.StatusOK}, + // Writes: the component rejected what was asked for. + {"blocklist refresh", "RefreshBlocklists", http.MethodPost, "/api/blocklists/refresh", "", http.StatusInternalServerError}, + {"blocklist toggle is a 404 for any failure", "ToggleBlocklist", http.MethodPost, "/api/blocklists/toggle", `{"name":"hosts","enabled":true}`, http.StatusNotFound}, + {"domain add", "AddDomain", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, http.StatusBadRequest}, + {"domain delete", "DeleteDomain", http.MethodDelete, "/api/domains/a.example.com", "", http.StatusNotFound}, + {"clients put", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, http.StatusBadRequest}, + {"upstreams put", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, http.StatusBadRequest}, + {"log clear", "ClearLog", http.MethodPost, "/api/logs/clear", "", http.StatusInternalServerError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + setFail(h, tc.fail, errHook) + res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key") + if res.status != tc.want { + t.Fatalf("%s %s with a failing %s hook = %d, want %d: %s", + tc.method, tc.path, tc.fail, res.status, tc.want, res.body) + } + if got, _ := res.json(t)["error"].(string); got == "" { + t.Errorf("response has no error message: %s", res.body) + } + // The server has to still be serving: the admin API is the only + // way to fix a broken instance. + if got := h.get("/api/whoami").status; got != http.StatusOK { + t.Errorf("/whoami after a failing hook = %d, want 200", got) + } + }) + } +} + +// TestHookErrorsAreFieldedForConfigWrites checks the two config-writing routes +// split a multi-line validator error into the fields the editor renders, so a +// rejected write points at the offending lines instead of dumping prose. +func TestHookErrorsAreFieldedForConfigWrites(t *testing.T) { + cases := []struct { + name string + fail string + method string + path string + body string + }{ + {"acl", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`}, + {"upstreams", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + setFail(h, tc.fail, errMultiLine) + res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Fatalf("%s %s = %d, want 400: %s", tc.method, tc.path, res.status, res.body) + } + fields, _ := res.json(t)["fields"].([]any) + if len(fields) != 2 { + t.Fatalf("fields = %v, want the two lines of the error", fields) + } + // Sorted, so the editor can diff two runs. + if fields[0] != "first problem" || fields[1] != "second problem" { + t.Errorf("fields = %v, want them split and sorted", fields) + } + }) + } +} + +// TestPanickingWriteHookIsRecovered checks the recoverer wraps the whole chain +// and not just the read handlers the earlier test covers: a write that panics +// must be a 500 and must not have half-applied anything. +func TestPanickingWriteHookIsRecovered(t *testing.T) { + h := newHarness(t, nil) + h.fake.mu.Lock() + h.fake.panicOn["PutACL"] = true + h.fake.mu.Unlock() + + res := h.do(http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "X-API-Key: rw-key") + if res.status != http.StatusInternalServerError { + t.Fatalf("a panicking write hook = %d, want 500: %s", res.status, res.body) + } + if len(h.fake.aclPut) != 0 { + t.Error("a panicking write recorded a persisted ACL") + } + if got := h.get("/api/clients").status; got != http.StatusOK { + t.Errorf("/clients after the panic = %d, want 200", got) + } +} diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go new file mode 100644 index 0000000..0c5bfb1 --- /dev/null +++ b/internal/api/metrics_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// stubMetrics is the exposition handler the app would have installed. The api +// package takes it as a func so it does not import the metrics package, which +// means a test can hand it a two-line stand-in and assert on the plumbing +// (routing, auth, content type) instead of on Prometheus internals. +func stubMetrics() http.Handler { + body := "# HELP fancydns_test_metric stub\n# TYPE fancydns_test_metric gauge\nfancydns_test_metric 1\n" + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + _, _ = w.Write([]byte(body)) + }) +} + +// wireMetrics installs a stub exposition handler and restores the "nothing +// installed" state afterwards, so the package-level handler does not leak into +// the next test. +func wireMetrics(t *testing.T, h http.Handler) { + t.Helper() + SetMetricsHandler(h) + t.Cleanup(func() { SetMetricsHandler(nil) }) +} + +// TestMetricsDisabledIsNotRouted pins the default: with metrics.enabled false +// there is no /metrics route at all, so the 404 is the mux's and not a handler +// answering "metrics are disabled". A scrape of a disabled instance must fail +// loudly enough to be noticed. +func TestMetricsDisabledIsNotRouted(t *testing.T) { + wireMetrics(t, stubMetrics()) + h := newHarness(t, nil) // testConfig leaves Metrics.Enabled false + res := h.get("/api/metrics") + if res.status != http.StatusNotFound { + t.Fatalf("GET /api/metrics with metrics disabled = %d, want 404: %s", res.status, res.body) + } + if strings.Contains(string(res.body), "fancydns_test_metric") { + t.Error("a disabled instance served metrics") + } +} + +// TestMetricsServesExposition checks the enabled, unprotected case: no API key +// needed, the Prometheus text content type comes through, and the body is the +// handler's. +func TestMetricsServesExposition(t *testing.T) { + wireMetrics(t, stubMetrics()) + h := newHarness(t, func(c *config.Config) { + c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false} + }) + res := h.do(http.MethodGet, "/api/metrics", "") + if res.status != http.StatusOK { + t.Fatalf("GET /api/metrics = %d, want 200: %s", res.status, res.body) + } + if got := res.header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") { + t.Errorf("Content-Type = %q, want the Prometheus text/plain form", got) + } + if !strings.Contains(string(res.body), "fancydns_test_metric 1") { + t.Errorf("body does not carry the exposition: %q", res.body) + } + // metrics.path is documented as unused (README: "the endpoint is always + // ${api.base_path}/metrics"), so a config that sets it to something else + // must not move the endpoint. Pinned here because the natural-looking fix + // — honour the field — is a URL change under a running scrape. + h2 := newHarness(t, func(c *config.Config) { + c.Metrics = config.Metrics{Enabled: true, Path: "/internal/metrics", Protect: false} + }) + if res := h2.do(http.MethodGet, "/api/metrics", ""); res.status != http.StatusOK { + t.Errorf("GET /api/metrics with a custom metrics path = %d, want 200: %s", res.status, res.body) + } + if res := h2.do(http.MethodGet, "/internal/metrics", ""); res.status != http.StatusNotFound { + t.Errorf("GET /internal/metrics = %d, want 404: metrics.path is not honoured: %s", res.status, res.body) + } +} + +// TestMetricsProtectedRequiresAKey checks metrics.protect: the scrape endpoint +// exposes client addresses and queried domains, so without a key it must not +// answer, and with one it must. The principal is reused from the authed +// routes, so a read-only key is enough. +func TestMetricsProtectedRequiresAKey(t *testing.T) { + wireMetrics(t, stubMetrics()) + h := newHarness(t, func(c *config.Config) { + c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: true} + }) + cases := []struct { + name string + header string + want int + }{ + {"no key", "", http.StatusUnauthorized}, + {"wrong key", "X-API-Key: nope", http.StatusUnauthorized}, + {"read-only key", "X-API-Key: ro-key", http.StatusOK}, + {"full-access key", "X-API-Key: rw-key", http.StatusOK}, + {"basic credentials", "Authorization: Basic YWRtaW46YWRtaW4=", http.StatusUnauthorized}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := h.do(http.MethodGet, "/api/metrics", "", tc.header) + if res.status != tc.want { + t.Fatalf("GET /api/metrics with %s = %d, want %d: %s", tc.name, res.status, tc.want, res.body) + } + if tc.want == http.StatusOK && !strings.Contains(string(res.body), "fancydns_test_metric") { + t.Errorf("authorised scrape did not reach the handler: %q", res.body) + } + }) + } + if got := h.do(http.MethodGet, "/api/metrics", "").json(t)["error"]; got == nil { + t.Error("the unauthenticated scrape did not answer with a JSON error") + } +} + +// TestMetricsHandlerNotWired pins the third state, which is easy to leave +// untested and looks like a product bug when it happens: metrics.enabled true +// but the app never installed the handler. The route answers 501 rather than +// panicking on a nil handler, because the api package holds it as a func it +// never sets itself. +func TestMetricsHandlerNotWired(t *testing.T) { + wireMetrics(t, nil) // enabled, but nothing installed + h := newHarness(t, func(c *config.Config) { + c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false} + }) + res := h.do(http.MethodGet, "/api/metrics", "") + if res.status != http.StatusNotImplemented { + t.Fatalf("GET /api/metrics with no handler wired = %d, want 501: %s", res.status, res.body) + } + // http.Error, so this one is plain text rather than the JSON envelope the + // rest of the API uses. + if !strings.Contains(string(res.body), "metrics not available") { + t.Errorf("body = %q, want it to say metrics not available", res.body) + } +} diff --git a/internal/api/ratelimit_test.go b/internal/api/ratelimit_test.go new file mode 100644 index 0000000..5e7434e --- /dev/null +++ b/internal/api/ratelimit_test.go @@ -0,0 +1,356 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// rateLimited is the config knob every test in this file turns: the per-minute +// request budget and the per-IP failure lockout are the same struct. +func rateLimited(c *config.Config, perMinute, failures int, lockout time.Duration) { + modeAPIKey(c) + c.API.RateLimit = config.AuthRate{PerMinute: perMinute, Failures: failures, Lockout: config.Duration(lockout)} +} + +// behindProxy is the X-Forwarded-For header that makes a request look like it +// came from ip. The harness's own connections come from 127.0.0.1, so a test +// that wants two distinct clients has to trust the loopback proxy. +func behindProxy(ip string) string { return "X-Forwarded-For: " + ip } + +// badLogin posts a login with a key that is not configured. It is what a +// brute-force attempt looks like from the server's side. +func badLogin(h *harness, header string) response { + return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"nope"}`, header) +} + +// goodLogin posts a login with the key the harness was configured with. +func goodLogin(h *harness, header string) response { + return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"rw-key"}`, header) +} + +// TestLoginBruteForceEventuallyHitsTheRateLimiter is the brute-force case, and +// the reason api.rate_limit exists at all: hammering the login endpoint with +// guessed keys must eventually be refused with 429 rather than answered with an +// unlimited stream of 401s. +// +// The per_minute limit is documented as "requests per minute per IP for the +// login endpoint", and the login route is registered through the identity +// wrapper, so nothing used to ever count these requests. +func TestLoginBruteForceEventuallyHitsTheRateLimiter(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + // A generous per-minute budget so the lockout, not the request + // count, is what stops the attack. + rateLimited(c, 1000, 3, 5*time.Minute) + }) + + var locked response + for i := 0; i < 20; i++ { + res := badLogin(h, "") + if res.status == http.StatusUnauthorized { + continue + } + locked = res + break + } + if locked.status != http.StatusTooManyRequests { + t.Fatalf("20 bad logins from one IP never hit the rate limit: last status %d, want 429: %s", + locked.status, locked.body) + } + if locked.header.Get("Retry-After") == "" { + t.Error("429 from the login limiter carries no Retry-After header") + } + + // A *correct* key during the lockout must be refused too. Otherwise the + // lockout is only an obstacle to the attacker and the operator whose key + // was typo'd gets in anyway. + res := goodLogin(h, "") + if res.status != http.StatusTooManyRequests { + t.Fatalf("a correct key during the lockout = %d, want 429: %s", res.status, res.body) + } + if _, ok := res.json(t)["token"]; ok { + t.Error("the lockout still issued a session token") + } + + // The lockout is not a login-only affair in this design: the same per-IP + // state gates the rest of the API, so a brute-force attempt from a host + // that also runs the console cannot keep hammering other routes either. + if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests { + t.Errorf("an authed route during the lockout = %d, want 429: %s", res.status, res.body) + } +} + +// TestLoginLockoutExpiresWithTheClock checks the lockout is temporary: once the +// configured lockout has passed, the same IP is served again. The clock is the +// server's, so the test advances it instead of sleeping. +func TestLoginLockoutExpiresWithTheClock(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + rateLimited(c, 1000, 3, 5*time.Minute) + }) + base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + now := base + h.srv.now = func() time.Time { return now } + + for i := 0; i < 3; i++ { + if res := badLogin(h, ""); res.status != http.StatusUnauthorized { + t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body) + } + } + + // Still inside the lockout window. + now = base.Add(5*time.Minute - time.Second) + if res := goodLogin(h, ""); res.status != http.StatusTooManyRequests { + t.Fatalf("one second before the lockout expires = %d, want 429: %s", res.status, res.body) + } + + // Past it. + now = base.Add(5*time.Minute + time.Second) + res := goodLogin(h, "") + if res.status != http.StatusOK { + t.Fatalf("login after the lockout expired = %d, want 200: %s", res.status, res.body) + } + if _, ok := res.json(t)["token"]; !ok { + t.Error("login after the lockout expired returned no token") + } +} + +// TestLoginLockoutIsPerIP checks one attacker cannot lock out everyone else +// behind the same proxy: the state is keyed on the forwarded client address, +// which is only honoured from a trusted proxy. +func TestLoginLockoutIsPerIP(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + rateLimited(c, 1000, 3, 5*time.Minute) + c.API.TrustedProxies = []string{"127.0.0.0/8"} + }) + const attacker = "198.51.100.7" + for i := 0; i < 10; i++ { + if res := badLogin(h, behindProxy(attacker)); res.status == http.StatusOK { + t.Fatalf("bad login %d was accepted: %s", i+1, res.body) + } + } + if res := badLogin(h, behindProxy(attacker)); res.status != http.StatusTooManyRequests { + t.Fatalf("the attacker was never locked out: %d: %s", res.status, res.body) + } + // A different client behind the same proxy must be untouched. + res := goodLogin(h, behindProxy("203.0.113.9")) + if res.status != http.StatusOK { + t.Fatalf("a second client behind the same proxy = %d, want 200: %s", res.status, res.body) + } + // And so must the proxy's own address (no X-Forwarded-For at all). + if res := goodLogin(h, ""); res.status != http.StatusOK { + t.Fatalf("the proxy address itself = %d, want 200: %s", res.status, res.body) + } +} + +// TestLoginLockoutDisabledWithoutFailures pins the config side: failures <= 0 +// turns the lockout off, so an operator who has not opted in cannot lock +// themselves out of the console. +func TestLoginLockoutDisabledWithoutFailures(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + rateLimited(c, 0, 0, 5*time.Minute) + }) + for i := 0; i < 25; i++ { + if res := badLogin(h, ""); res.status != http.StatusUnauthorized { + t.Fatalf("bad login %d = %d, want 401: no lockout is configured: %s", i+1, res.status, res.body) + } + } + if res := goodLogin(h, ""); res.status != http.StatusOK { + t.Fatalf("login after 25 failures = %d, want 200: %s", res.status, res.body) + } +} + +// TestSuccessfulLoginClearsTheFailureCounter checks a success resets the +// failure counter: an operator who fat-fingers the password three times, waits +// out the lockout and then logs in correctly must not be one typo away from +// another lockout. +func TestSuccessfulLoginClearsTheFailureCounter(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + rateLimited(c, 1000, 3, time.Minute) + }) + base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + now := base + h.srv.now = func() time.Time { return now } + + for i := 0; i < 3; i++ { + badLogin(h, "") + } + now = base.Add(2 * time.Minute) // past the one-minute lockout + if res := goodLogin(h, ""); res.status != http.StatusOK { + t.Fatalf("login after the lockout = %d, want 200: %s", res.status, res.body) + } + // The counter started again: without the reset the first post-success + // failure would already be locked out. Three more failures are still + // answered 401 and the fourth re-arms the lockout, which is exactly what + // "the counter restarted" means. + for i := 0; i < 3; i++ { + if res := badLogin(h, ""); res.status != http.StatusUnauthorized { + t.Fatalf("post-success failure %d = %d, want 401: the counter was not reset", i+1, res.status) + } + } + if res := badLogin(h, ""); res.status != http.StatusTooManyRequests { + t.Errorf("the fourth post-success failure = %d, want 429: %s", res.status, res.body) + } +} + +// TestSuccessfulLoginDoesNotResetTheRequestWindow pins a deliberate separation: +// a successful login clears the *failure* state, but it must not hand the +// client a fresh per-minute request budget. clearLoginFailures used to delete +// the whole per-IP state, so one successful login in the middle of a burst +// wiped the rate-limit window too — an attacker holding one valid key could use +// it to make brute force free, and the fix is to only reset what the login +// knows about. +func TestSuccessfulLoginDoesNotResetTheRequestWindow(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + // Four requests a minute and no lockout at all, so the window is the + // only thing that can refuse: three failures, then a correct key, + // then the budget is spent. + rateLimited(c, 4, 0, 5*time.Minute) + }) + for i := 0; i < 3; i++ { + if res := badLogin(h, ""); res.status != http.StatusUnauthorized { + t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body) + } + } + if res := goodLogin(h, ""); res.status != http.StatusOK { + t.Fatalf("the correct key = %d, want 200: %s", res.status, res.body) + } + res := badLogin(h, "") + if res.status != http.StatusTooManyRequests { + t.Fatalf("after four requests in the window = %d, want 429: a successful login must not reset the window: %s", + res.status, res.body) + } +} + +// TestAuthedRouteRateLimit checks the per-minute budget on the authenticated +// routes, which is where allowRequest has always been wired: N+1 requests in a +// window get 429 with a Retry-After, and the Nth is still served. +func TestAuthedRouteRateLimit(t *testing.T) { + h := newHarness(t, func(c *config.Config) { rateLimited(c, 5, 0, 5*time.Minute) }) + for i := 0; i < 5; i++ { + if res := h.get("/api/whoami"); res.status != http.StatusOK { + t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body) + } + } + res := h.get("/api/whoami") + if res.status != http.StatusTooManyRequests { + t.Fatalf("the 6th request in a minute = %d, want 429: %s", res.status, res.body) + } + if got := res.header.Get("Retry-After"); got == "" { + t.Error("429 from the per-minute limit carries no Retry-After header") + } + // The refused request must not have reached the handler. + if h.fake.count("Status") != 0 { + t.Error("a rate-limited request still reached a hook") + } +} + +// TestAuthedRouteRateLimitWindowResets checks the window is a minute wide and +// is driven by the server's clock, not by a timer that has to expire in real +// time. +func TestAuthedRouteRateLimitWindowResets(t *testing.T) { + h := newHarness(t, func(c *config.Config) { rateLimited(c, 3, 0, 5*time.Minute) }) + base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + now := base + h.srv.now = func() time.Time { return now } + + for i := 0; i < 3; i++ { + if res := h.get("/api/whoami"); res.status != http.StatusOK { + t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body) + } + } + if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests { + t.Fatalf("the 4th request = %d, want 429: %s", res.status, res.body) + } + // One second before the window rolls over is still inside it. + now = base.Add(time.Minute - time.Second) + if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests { + t.Fatalf("one second before the window rolls = %d, want 429: %s", res.status, res.body) + } + now = base.Add(time.Minute) + if res := h.get("/api/whoami"); res.status != http.StatusOK { + t.Fatalf("first request in the next window = %d, want 200: %s", res.status, res.body) + } +} + +// TestAuthedRouteRateLimitIsPerIP checks the budget is per client address, so +// one noisy operator cannot lock out the rest of the console's users. +func TestAuthedRouteRateLimitIsPerIP(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + rateLimited(c, 2, 0, 5*time.Minute) + c.API.TrustedProxies = []string{"127.0.0.0/8"} + }) + for i := 0; i < 3; i++ { + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("198.51.100.1")) + want := http.StatusOK + if i == 2 { + want = http.StatusTooManyRequests + } + if res.status != want { + t.Fatalf("request %d from the noisy client = %d, want %d: %s", i+1, res.status, want, res.body) + } + } + if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("203.0.113.9")); res.status != http.StatusOK { + t.Fatalf("a different client = %d, want 200: %s", res.status, res.body) + } +} + +// TestAuthedRouteRateLimitDisabledWhenNotConfigured pins the default: with +// rate_limit unset (which is what a config that never mentions it gets) the +// authenticated routes are not limited at all. +func TestAuthedRouteRateLimitDisabledWhenNotConfigured(t *testing.T) { + h := newHarness(t, nil) // testConfig leaves RateLimit zero + for i := 0; i < 20; i++ { + if res := h.get("/api/whoami"); res.status != http.StatusOK { + t.Fatalf("request %d with no rate limit configured = %d, want 200: %s", i+1, res.status, res.body) + } + } +} + +// TestRateLimitCleanupDropsStaleEntries exercises the opportunistic cleanup in +// allowRequest. There is no sweeper goroutine: the per-IP map only shrinks +// when a request comes in and finds it over the 4096-entry cap, so an attacker +// rotating source addresses is the case that matters, and an entry that has +// not been touched for ten minutes is the one that goes. +func TestRateLimitCleanupDropsStaleEntries(t *testing.T) { + h := newHarness(t, func(c *config.Config) { + // A budget no test can hit: this is about the map, not the counter. + rateLimited(c, 1_000_000, 0, time.Minute) + }) + base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + now := base + h.srv.now = func() time.Time { return now } + + // Fill the map past the cap with one request per synthetic client. + allow := func(ip string) { + r := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + r.RemoteAddr = ip + ":40000" + if !h.srv.allowRequest(httptest.NewRecorder(), r) { + t.Fatalf("client %s was rate limited", ip) + } + } + for i := 0; i < 4097; i++ { + allow(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256)) + } + if got := len(h.srv.limits); got <= 4096 { + t.Fatalf("the map holds %d entries, want more than the 4096 cap", got) + } + + // Nothing is stale yet, so a request at the same instant must not shrink + // the map: the cap is a trigger, not a deadline. + allow("10.9.9.9") + if got := len(h.srv.limits); got <= 4096 { + t.Errorf("the cleanup dropped %d entries that were not stale", 4097+1-got) + } + + // Past the ten-minute idle horizon every entry is stale, and the next + // request sweeps them. + now = base.Add(11 * time.Minute) + allow("10.9.9.8") + if got := len(h.srv.limits); got > 2 { + t.Errorf("after the idle horizon the map holds %d entries, want only the two live clients", got) + } +} diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go new file mode 100644 index 0000000..ae6fd55 --- /dev/null +++ b/internal/api/settings_test.go @@ -0,0 +1,492 @@ +package api + +import ( + "net/http" + "strings" + "testing" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// post is the authenticated write the settings routes need. +func (h *harness) post(path string) response { + h.t.Helper() + return h.do(http.MethodPost, path, "", "X-API-Key: rw-key") +} + +// newYAML renders a complete, valid document with one field changed, as it +// would sit in the config file. The settings editor sends a whole document, and +// so do these tests: a partial one lands on top of config.Default, which leaves +// the blocklist enabled and therefore invalid. +func newYAML(t *testing.T, mutate func(*config.Config)) string { + t.Helper() + cfg := config.Default() + cfg.Block.Enabled = false + if mutate != nil { + mutate(cfg) + } + doc, err := config.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + return string(doc) +} + +// newDoc is newYAML wrapped in the JSON envelope the settings routes expect. +func newDoc(t *testing.T, mutate func(*config.Config)) string { + t.Helper() + return settingsBody(t, mutate) +} + +// TestSettingsGet pins the payload the settings editor renders from: the file +// on disk as the editable text, the effective document the running server +// actually has, and the defaults as the reset target. +func TestSettingsGet(t *testing.T) { + h := newHarness(t, nil) + res := h.get("/api/settings") + if res.status != http.StatusOK { + t.Fatalf("GET /api/settings = %d: %s", res.status, res.body) + } + body := res.json(t) + if body["path"] != h.path { + t.Errorf("path = %v, want %q", body["path"], h.path) + } + if body["config_yaml"] != h.readConfig() { + t.Error("config_yaml is not the document on disk") + } + if !strings.Contains(body["effective_yaml"].(string), "api_keys") { + t.Error("effective_yaml does not carry the running auth config") + } + if !strings.Contains(body["defaults_yaml"].(string), "session_ttl") { + t.Error("defaults_yaml does not look like a default document") + } + if body["reload_available"] != true { + t.Errorf("reload_available = %v, want true for a config with a file", body["reload_available"]) + } + if body["writable"] != true { + t.Errorf("writable = %v, want true for a 0600 file in a temp dir", body["writable"]) + } + if body["last_error"] != "" { + t.Errorf("last_error = %v, want empty on a fresh server", body["last_error"]) + } +} + +// TestSettingsGetWithoutAConfigFile pins the fallback for an instance running +// with no file: reload is unavailable and the editor shows the effective +// document as the editable text, so the UI still works. +func TestSettingsGetWithoutAConfigFile(t *testing.T) { + h := newHarnessWithFile(t, testConfig(t), "") + body := h.get("/api/settings").json(t) + if body["path"] != "" { + t.Errorf("path = %v, want empty", body["path"]) + } + if body["reload_available"] != false { + t.Errorf("reload_available = %v, want false with no file", body["reload_available"]) + } + if body["config_yaml"] != body["effective_yaml"] { + t.Error("config_yaml should fall back to the effective document when there is no file") + } +} + +// TestSettingsPutRoundTrips is the happy path: a written document must reach +// the disk and the running config, and the version must move so the cluster +// notices. The round-trip is checked through config.Load, not by reading the +// store back, because a document that does not re-parse is the failure mode +// that costs an operator a restart. +func TestSettingsPutRoundTrips(t *testing.T) { + h := newHarness(t, nil) + before := h.store.Generation() + doc := newDoc(t, func(c *config.Config) { + c.Server.MaxConcurrent = 4242 + c.Log.Level = "debug" + }) + res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key") + if res.status != http.StatusOK { + t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body) + } + if body := res.json(t); body["ok"] != true { + t.Fatalf("response = %v, want ok", body) + } + if h.store.Generation() != before+1 { + t.Errorf("generation = %d, want %d", h.store.Generation(), before+1) + } + // The file on disk parses back to what was asked for. + back, err := config.Load(h.path) + if err != nil { + t.Fatalf("the written document does not re-parse: %v", err) + } + if back.Server.MaxConcurrent != 4242 || back.Log.Level != "debug" { + t.Errorf("reloaded config = max_concurrent %d, log.level %q", back.Server.MaxConcurrent, back.Log.Level) + } + // And the running server agrees with the file. + if got := h.store.Get().Server.MaxConcurrent; got != 4242 { + t.Errorf("store max_concurrent = %d, want 4242", got) + } + // A follow-up GET re-renders the new document, so the editor does not + // snap back to the old text. + if got := h.get("/api/settings").json(t)["config_yaml"]; got != h.readConfig() { + t.Error("GET /api/settings after a write does not match the file") + } +} + +// TestSettingsPutRejects walks the ways a settings write is refused. Each of +// them is a case where the operator would otherwise find out from a restart +// that the change was lost. +func TestSettingsPutRejects(t *testing.T) { + cases := []struct { + name string + body string + wantField string // substring the error must contain + }{ + {"no body", "", "malformed request body"}, + {"not json", "{", "malformed request body"}, + {"unknown field", `{"config_yaml": "server:\n max_concurrent: 1\n", "comment": "x"}`, "malformed request body"}, + {"empty document", `{"config_yaml": " \n"}`, "config_yaml is empty"}, + {"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"}, + { + "invalid config", + `{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, + "invalid config", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + before := h.readConfig() + res := h.do(http.MethodPut, "/api/settings", tc.body, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body) + } + if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, tc.wantField) { + t.Errorf("error = %q, want it to mention %q", msg, tc.wantField) + } + if h.readConfig() != before { + t.Error("a refused write still touched the config file") + } + if h.store.Generation() != 0 { + t.Error("a refused write still swapped the config") + } + }) + } +} + +// TestSettingsPutValidationErrorsAreFielded checks the aggregated validator +// output reaches the editor as per-field messages, which is the whole point of +// writing a config through a form. +func TestSettingsPutValidationErrorsAreFielded(t *testing.T) { + h := newHarness(t, nil) + res := h.do(http.MethodPut, "/api/settings", + `{"config_yaml": "upstream:\n policy: sideways\n servers: []\n"}`, "X-API-Key: rw-key") + if res.status != http.StatusBadRequest { + t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body) + } + fields, _ := res.json(t)["fields"].([]any) + if len(fields) < 2 { + t.Fatalf("fields = %v, want one message per validation error", fields) + } + joined := "" + for _, f := range fields { + s, _ := f.(string) + joined += s + "\n" + } + for _, want := range []string{"upstream.policy", "upstream.servers must not be empty"} { + if !strings.Contains(joined, want) { + t.Errorf("fields %q do not mention %q", joined, want) + } + } +} + +// TestSettingsPutRefusedWhenWritesAreDisabled pins allow_config_write: false. +// A read-only key and a disabled write are different refusals, and an operator +// needs to be able to tell them apart from the 403 text. +func TestSettingsPutRefusedWhenWritesAreDisabled(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.API.AllowConfigWrite = false }) + before := h.readConfig() + res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key") + if res.status != http.StatusForbidden { + t.Fatalf("PUT /api/settings = %d, want 403: %s", res.status, res.body) + } + if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "disabled") { + t.Errorf("error = %q, want it to say writes are disabled", msg) + } + if h.readConfig() != before { + t.Error("a refused write still touched the config file") + } +} + +// TestSettingsPutNeedsAWritableFile pins the 500 path: a valid document that +// cannot be persisted must say so rather than answering ok and losing the +// change. The harness can produce this without a permissions race by giving +// the store no path at all. +func TestSettingsPutNeedsAWritableFile(t *testing.T) { + h := newHarnessWithFile(t, testConfig(t), "") + res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key") + if res.status != http.StatusInternalServerError { + t.Fatalf("PUT /api/settings with no file = %d, want 500: %s", res.status, res.body) + } + if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "cannot persist") { + t.Errorf("error = %q, want it to say it cannot persist", msg) + } + if h.store.Generation() != 0 { + t.Error("a config that could not be written was swapped in anyway") + } +} + +// TestSettingsValidate checks the dry run. It must never touch the running +// config or the file, and it reports a valid document as valid — including an +// empty one, which decodes onto the defaults, so a "clear the editor and +// save" lands on a complete default config rather than an error. +func TestSettingsValidate(t *testing.T) { + h := newHarness(t, nil) + before := h.readConfig() + + t.Run("valid document", func(t *testing.T) { + res := h.do(http.MethodPost, "/api/settings/validate", newDoc(t, nil), "X-API-Key: rw-key") + if res.status != http.StatusOK { + t.Fatalf("validate = %d, want 200: %s", res.status, res.body) + } + body := res.json(t) + if body["ok"] != true { + t.Errorf("ok = %v, want true: %v", body["ok"], body["errors"]) + } + if errs, _ := body["errors"].([]any); len(errs) != 0 { + t.Errorf("errors = %v, want none", errs) + } + // Warnings are the point of a validator: a valid config can still be + // a bad idea, and this document leaves the blocklist off. + if warns, _ := body["warnings"].([]any); len(warns) == 0 { + t.Error("no warnings for a document with the blocklist disabled") + } + }) + + t.Run("empty document is not valid", func(t *testing.T) { + // Not the obvious answer, and worth pinning: an empty document + // decodes onto config.Default, and the shipped defaults enable the + // blocklist without a single source, so Validate rejects them. The + // UI therefore cannot be used to "reset to nothing"; a reset to + // defaults has to send a real default document. + res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key") + if res.status != http.StatusOK { + t.Fatalf("validate = %d, want 200: %s", res.status, res.body) + } + body := res.json(t) + if body["ok"] != false { + t.Errorf("ok = %v for an empty document, want false: %v", body["ok"], body["errors"]) + } + }) + + t.Run("broken yaml", func(t *testing.T) { + res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": "api: [\n"}`, "X-API-Key: rw-key") + if res.status != http.StatusOK { + t.Fatalf("validate = %d, want 200: the answer is a report, not a refusal: %s", res.status, res.body) + } + body := res.json(t) + if body["ok"] != false { + t.Errorf("ok = %v, want false", body["ok"]) + } + errs, _ := body["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("errors = %v, want exactly one", errs) + } + first, _ := errs[0].(map[string]any) + if first["field"] != "config" { + t.Errorf("field = %v, want \"config\" for a parse error", first["field"]) + } + }) + + t.Run("invalid config", func(t *testing.T) { + res := h.do(http.MethodPost, "/api/settings/validate", + `{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, "X-API-Key: rw-key") + if res.status != http.StatusOK { + t.Fatalf("validate = %d, want 200: %s", res.status, res.body) + } + body := res.json(t) + if body["ok"] != false { + t.Fatalf("ok = %v, want false: %s", body["ok"], res.body) + } + errs, _ := body["errors"].([]any) + if len(errs) == 0 { + t.Fatal("no errors reported for an invalid config") + } + first, _ := errs[0].(map[string]any) + joined := "" + for _, f := range errs { + m, _ := f.(map[string]any) + msg, _ := m["message"].(string) + joined += msg + "\n" + } + if !strings.Contains(joined, "blocklist is enabled but no lists") { + t.Errorf("errors = %q, want the blocklist complaint", joined) + } + // fieldOf falls back to "config" for a message with no dotted path, + // which is what this one is. Pinned so the editor's field highlighting + // does not quietly start pointing at a different thing. + if first["field"] != "config" { + t.Errorf("field = %v, want \"config\" for a message with no path", first["field"]) + } + }) + + t.Run("malformed request body", func(t *testing.T) { + if res := h.post("/api/settings/validate"); res.status != http.StatusBadRequest { + t.Fatalf("validate with no body = %d, want 400: %s", res.status, res.body) + } + }) + + if h.readConfig() != before { + t.Error("validate touched the config file") + } + if h.store.Generation() != 0 { + t.Error("validate swapped the running config") + } +} + +// TestSettingsReload checks the reload path end to end against a real file: a +// changed document on disk reaches the running config, a broken one leaves the +// running config alone and records why, and there is no file to reload from at +// all when the instance was started without one. +func TestSettingsReload(t *testing.T) { + t.Run("applies a changed document", func(t *testing.T) { + h := newHarness(t, nil) + h.writeConfig(t, newYAML(t, func(c *config.Config) { c.Log.Level = "debug" })) + gen := h.store.Generation() + res := h.post("/api/settings/reload") + if res.status != http.StatusOK { + t.Fatalf("reload = %d: %s", res.status, res.body) + } + if body := res.json(t); body["ok"] != true { + t.Fatalf("response = %v, want ok", body) + } + if got := h.store.Get().Log.Level; got != "debug" { + t.Errorf("running log level = %q, want debug", got) + } + if h.store.Generation() != gen+1 { + t.Errorf("generation = %d, want %d", h.store.Generation(), gen+1) + } + }) + + t.Run("a broken document keeps the running config", func(t *testing.T) { + h := newHarness(t, nil) + level := h.store.Get().Log.Level + gen := h.store.Generation() + h.writeConfig(t, "this: is: not: yaml:\n - [\n") + res := h.post("/api/settings/reload") + if res.status != http.StatusInternalServerError { + t.Fatalf("reload of a broken document = %d, want 500: %s", res.status, res.body) + } + if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "reload failed") { + t.Errorf("error = %q, want it to say the reload failed", msg) + } + // A failed reload must not take the resolver down with it: the old + // config keeps serving and the reason is reported by /status. + if got := h.store.Get().Log.Level; got != level { + t.Errorf("running log level = %q, want the previous %q", got, level) + } + if h.store.Generation() != gen { + t.Errorf("generation = %d, want it unchanged at %d", h.store.Generation(), gen) + } + if h.store.LastError() == "" { + t.Error("a failed reload recorded no last_error") + } + if last := h.get("/api/status").json(t)["config"].(map[string]any)["last_error"]; last == "" { + t.Error("/status does not report the failed reload") + } + }) + + t.Run("no config file", func(t *testing.T) { + h := newHarnessWithFile(t, testConfig(t), "") + res := h.post("/api/settings/reload") + if res.status != http.StatusBadRequest { + t.Fatalf("reload with no file = %d, want 400: %s", res.status, res.body) + } + }) +} + +// TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart pins a known limitation +// (STATUS.md, "Auth configuration is frozen at New"): the authenticator, the +// base path and the trusted-proxy list are captured when New builds the server, +// so a settings write answers ok and the running server keeps the old values. +// The write endpoint cannot apply its own auth change; only a restart can. +// +// This test exists to make the limitation visible, not to endorse it. If the +// freezing is ever fixed, this test is the one that should fail, and the fix +// has to make each case below actually take effect. +func TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart(t *testing.T) { + t.Run("api key", func(t *testing.T) { + h := newHarness(t, nil) + doc := newDoc(t, func(c *config.Config) { + c.API.Auth = config.Auth{ + Mode: "apikey", + APIKeys: []string{"new-key"}, + SessionTTL: config.Duration(time.Hour), + TrustLoopback: false, + ReadOnlyAPIKeys: []string{"ro-key"}, + } + }) + if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK { + t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body) + } + // The store has the new key... + if got := h.store.Get().API.Auth.APIKeys; len(got) != 1 || got[0] != "new-key" { + t.Fatalf("store api_keys = %v, want the written key", got) + } + // ...and the running server still does not: a pinned limitation. + if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: new-key"); res.status != http.StatusUnauthorized { + t.Errorf("the written key = %d, want 401 until a restart (pinned limitation): %s", res.status, res.body) + } + if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusOK { + t.Errorf("the original key = %d, want 200: %s", res.status, res.body) + } + // The login endpoint, which is what brute force goes through, is + // frozen the same way. + res := h.do(http.MethodPost, "/api/auth/login", `{"api_key":"new-key"}`) + if res.status != http.StatusUnauthorized { + t.Errorf("login with the written key = %d, want 401 until a restart: %s", res.status, res.body) + } + }) + + t.Run("base path", func(t *testing.T) { + h := newHarness(t, nil) + doc := newDoc(t, func(c *config.Config) { c.API.BasePath = "/admin" }) + if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK { + t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body) + } + if got := h.get("/api/whoami").status; got != http.StatusOK { + t.Errorf("the old base path = %d, want 200: the mux is built once (pinned limitation)", got) + } + if got := h.get("/admin/whoami").status; got != http.StatusNotFound { + t.Errorf("the written base path = %d, want 404 until a restart (pinned limitation)", got) + } + }) + + t.Run("trusted proxies", func(t *testing.T) { + // Two requests a minute: the PUT below is the first, so the two + // spoofed reads that follow land in the same per-IP bucket as the + // loopback client if — and only if — the written trusted_proxies is + // not in effect. + h := newHarness(t, func(c *config.Config) { + c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)} + }) + doc := newDoc(t, func(c *config.Config) { + c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)} + // Trust every proxy, which would make any X-Forwarded-For + // authoritative if the list were re-read. + c.API.TrustedProxies = []string{"0.0.0.0/0"} + }) + if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK { + t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body) + } + if got := h.store.Get().API.TrustedProxies; len(got) != 1 { + t.Fatalf("store trusted_proxies = %v", got) + } + spoof := "X-Forwarded-For: 198.51.100.7" + if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof); res.status != http.StatusOK { + t.Fatalf("first spoofed request = %d, want 200: %s", res.status, res.body) + } + res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof) + if res.status != http.StatusTooManyRequests { + t.Errorf("the second spoofed request = %d, want 429: the forwarded address is still ignored, so it shares the loopback bucket (pinned limitation): %s", + res.status, res.body) + } + }) +} diff --git a/internal/api/sse_test.go b/internal/api/sse_test.go new file mode 100644 index 0000000..d7d1539 --- /dev/null +++ b/internal/api/sse_test.go @@ -0,0 +1,243 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// sseEvent is one parsed SSE frame: the event name and the JSON payload. +type sseEvent struct { + name string + data string +} + +// openSSE opens GET /logs/stream and returns a reader for the frames. The +// stream stays open until the context the caller passed is cancelled or the +// body is closed, so every test here has to do one or the other. +func openSSE(t *testing.T, h *harness, ctx context.Context) (*http.Response, *bufio.Reader) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.ts.URL+"/api/logs/stream", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-API-Key", "rw-key") + res, err := h.ts.Client().Do(req) + if err != nil { + t.Fatalf("open the log stream: %v", err) + } + t.Cleanup(func() { _ = res.Body.Close() }) + return res, bufio.NewReader(res.Body) +} + +// nextEvent reads until a complete frame has arrived. Comment frames (the +// keep-alive ": ping") and the blank separator line are skipped. +func nextEvent(t *testing.T, br *bufio.Reader) sseEvent { + t.Helper() + var ev sseEvent + for { + line, err := br.ReadString('\n') + if err != nil { + t.Fatalf("read the log stream: %v", err) + } + line = strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(line, "event: "): + ev.name = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + ev.data = strings.TrimPrefix(line, "data: ") + case line == "": + if ev.name != "" || ev.data != "" { + return ev + } + } + } +} + +// freshUnsubSignal gives the SSE test its own one-shot unsubscribe channel. +// The harness closes a single shared channel through a sync.Once, so without +// this only the first stream test in the package could observe it. +func freshUnsubSignal(h *harness) <-chan struct{} { + h.fake.mu.Lock() + defer h.fake.mu.Unlock() + h.fake.unsub = make(chan struct{}) + h.fake.unsubOnce = sync.Once{} + return h.fake.unsub +} + +// TestSSEStreamHeadersAndEvents pins the SSE contract the live log view +// depends on: the streaming content type, the two headers that stop an +// intermediary from buffering the stream, a "ready" event so the UI can show it +// is connected, and exactly one "entry" event per query-log row. +func TestSSEStreamHeadersAndEvents(t *testing.T) { + h := newHarness(t, nil) + unsub := freshUnsubSignal(h) + // Queued before the stream opens: the fake's channel is buffered, so this + // is a deterministic ordering and not a race on the subscription. + want := []LogEntry{ + {ID: 1, Name: "a.example.com.", QTypeStr: "A", RCode: 0, Client: "198.51.100.1"}, + {ID: 2, Name: "b.example.com.", QTypeStr: "AAAA", RCode: 3, Client: "198.51.100.2", Blocked: true}, + {ID: 3, Name: "c.example.com.", QTypeStr: "TXT", RCode: 0, Client: "198.51.100.3"}, + } + for _, e := range want { + h.fake.stream <- e + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + res, br := openSSE(t, h, ctx) + + if got := res.Header.Get("Content-Type"); got != "text/event-stream" { + t.Errorf("Content-Type = %q, want text/event-stream", got) + } + // no-store, not no-cache: this stream is never replayable. + if got := res.Header.Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", got) + } + // Without this an nginx in front buffers the stream and the live view shows + // nothing until the buffer fills. + if got := res.Header.Get("X-Accel-Buffering"); got != "no" { + t.Errorf("X-Accel-Buffering = %q, want no", got) + } + + ready := nextEvent(t, br) + if ready.name != "ready" { + t.Fatalf("first event = %q, want ready", ready.name) + } + var readyBody map[string]any + if err := json.Unmarshal([]byte(ready.data), &readyBody); err != nil { + t.Fatalf("ready payload is not JSON: %v: %q", err, ready.data) + } + if readyBody["version"] != "test" { + t.Errorf("ready payload = %v, want it to carry the version", readyBody) + } + for i, e := range want { + got := nextEvent(t, br) + if got.name != "entry" { + t.Fatalf("event %d = %q, want entry", i, got.name) + } + var gotEntry LogEntry + if err := json.Unmarshal([]byte(got.data), &gotEntry); err != nil { + t.Fatalf("entry payload is not JSON: %v: %q", err, got.data) + } + if gotEntry.ID != e.ID || gotEntry.Name != e.Name || gotEntry.Client != e.Client { + t.Errorf("entry %d = %+v, want %+v", i, gotEntry, e) + } + } + cancel() + waitFor(t, 2*time.Second, "the subscriber to be released", func() bool { + return h.fake.subscribers() == 0 + }) + select { + case <-unsub: + default: + t.Error("the handler returned without running the unsubscribe hook") + } +} + +// TestSSEStreamUnsubscribesOnClientDisconnect checks a client that goes away +// does not leave a handler parked on the log channel: the request context is +// cancelled when the connection drops, and the handler must return and run its +// cancel hook, or every reconnect leaks a goroutine and a log subscriber. +func TestSSEStreamUnsubscribesOnClientDisconnect(t *testing.T) { + h := newHarness(t, nil) + unsub := freshUnsubSignal(h) + ctx, cancel := context.WithCancel(context.Background()) + _, br := openSSE(t, h, ctx) + if got := nextEvent(t, br); got.name != "ready" { + t.Fatalf("first event = %q, want ready", got.name) + } + waitFor(t, 2*time.Second, "the handler to subscribe", func() bool { + return h.fake.subscribers() == 1 + }) + + // Disconnect: dropping the request context closes the connection, which is + // what the server sees when a browser tab is closed. + cancel() + + waitFor(t, 2*time.Second, "the handler to unsubscribe", func() bool { + return h.fake.subscribers() == 0 + }) + select { + case <-unsub: + case <-time.After(2 * time.Second): + t.Fatal("the unsubscribe hook never ran after the client disconnected") + } +} + +// TestSSEStreamLeavesNoGoroutinesBehind opens and closes the stream repeatedly +// and asserts the goroutine count stays bounded. The bound, not an exact count: +// the HTTP client and server keep per-connection goroutines that wind down on +// their own, and a test that demands an exact number is a flake. The property +// worth having is that the count does not grow with the number of streams. +func TestSSEStreamLeavesNoGoroutinesBehind(t *testing.T) { + h := newHarness(t, nil) + runtime.GC() // keep the baseline honest: the count is what it is after a GC + before := runtime.NumGoroutine() + for i := 0; i < 25; i++ { + ctx, cancel := context.WithCancel(context.Background()) + _, br := openSSE(t, h, ctx) + if got := nextEvent(t, br); got.name != "ready" { + cancel() + t.Fatalf("stream %d: first event = %q, want ready", i, got.name) + } + cancel() + } + waitFor(t, 2*time.Second, "every subscriber to be released", func() bool { + return h.fake.subscribers() == 0 + }) + const bound = 8 // per-connection goroutines that may still be winding down + waitFor(t, 3*time.Second, "the goroutine count to settle", func() bool { + return runtime.NumGoroutine() <= before+bound + }) +} + +// TestSSEStreamEndsWhenTheSourceCloses covers the other exit from the handler +// loop: the hook's channel being closed, which is what a shutting-down log ring +// does. The response has to end rather than hang, and the unsubscribe hook must +// still run — a handler that only returns on the request context is a handler +// that leaks when the producer goes away. +func TestSSEStreamEndsWhenTheSourceCloses(t *testing.T) { + h := newHarness(t, nil) + unsub := freshUnsubSignal(h) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Closing the hook's channel is what a shutting-down log ring does. + close(h.fake.stream) + _, br := openSSE(t, h, ctx) + if got := nextEvent(t, br); got.name != "ready" { + t.Fatalf("first event = %q, want ready", got.name) + } + if _, err := br.ReadString('\n'); err == nil { + t.Error("the stream carried on after its source closed") + } + waitFor(t, 2*time.Second, "the handler to unsubscribe", func() bool { + return h.fake.subscribers() == 0 + }) + select { + case <-unsub: + case <-time.After(2 * time.Second): + t.Fatal("the unsubscribe hook never ran after the source closed") + } +} + +// TestSSEStreamRequiresAuth checks the stream is behind the same auth as the +// rest of the API: it is a live view of every client address and domain the +// instance sees, so an unauthenticated subscribe is a data leak, not a +// cosmetic one. +func TestSSEStreamRequiresAuth(t *testing.T) { + h := newHarness(t, nil) + res := h.do(http.MethodGet, "/api/logs/stream", "") + if res.status != http.StatusUnauthorized { + t.Fatalf("GET /api/logs/stream without a key = %d, want 401: %s", res.status, res.body) + } + if h.fake.count("SubscribeLog") != 0 { + t.Error("an unauthenticated request reached the subscribe hook") + } +} diff --git a/internal/blocklist/example_test.go b/internal/blocklist/example_test.go new file mode 100644 index 0000000..f31f585 --- /dev/null +++ b/internal/blocklist/example_test.go @@ -0,0 +1,143 @@ +package blocklist + +import ( + "path/filepath" + "slices" + "testing" +) + +// TestExampleRPZZoneBlocksWhatItSays runs the shipped policy zone through the +// exact path Manager.RefreshAll uses (parseRPZFiles, which prepends a per-file +// $ORIGIN derived from the file name) and pins the rules it produces. +// +// The origin is the reason this is a test and not a doc: a file called +// local.rpz.zone.example is read with the origin "local.", so the relative owner +// `*.doubleclick.net` became `*.doubleclick.net.local.` and the example blocked +// nothing. The example now spells every owner absolutely; if someone "tidies it +// up" back to relative names, the rules below stop matching and this fails. +func TestExampleRPZZoneBlocksWhatItSays(t *testing.T) { + path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example") + + rules, stats, err := parseRPZFiles([]string{path}) + if err != nil { + t.Fatalf("parseRPZFiles(%s): %v", path, err) + } + + type ruleWant struct { + name string + action Action + response string + } + want := []ruleWant{ + {"*.doubleclick.net.", ActionNXDomain, ""}, + {"*.ads.example.com.", ActionNXDomain, ""}, + {"*.tracker.example.net.", ActionNXDomain, ""}, + // A bare owner name in a policy zone is given the subdomain form, + // which covers the name's children but not the name itself; see + // TestExampleRPZZoneApexIsNotCovered. + {"*.analytics.example.org.", ActionNXDomain, ""}, + {"*.noisy-cdn.example.", ActionDrop, ""}, + {"*.metrics.internal.", ActionPassthrough, ""}, + {"*.blocked.example.", ActionRedirect, "192.0.2.10"}, + } + got := make([]ruleWant, 0, len(rules)) + for _, r := range rules { + got = append(got, ruleWant{r.Name, r.Action, r.Response}) + if r.TTL != 60 { + t.Errorf("rule %q has TTL %d, want the zone's $TTL 60", r.Name, r.TTL) + } + } + if !slices.Equal(got, want) { + t.Fatalf("rules mismatch\n got: %+v\nwant: %+v", got, want) + } + if stats.Rules != len(want) { + t.Errorf("stats.Rules = %d, want %d", stats.Rules, len(want)) + } + // The A and the AAAA share an owner, so one of them is deduplicated + // rather than compiled twice. + if stats.Duplicates != 1 { + t.Errorf("stats.Duplicates = %d, want 1 (the second blocked.example. record)", stats.Duplicates) + } +} + +// TestExampleRPZZoneMatchesTheNamesItNames builds the Set an instance would +// actually consult, so the pin above is about lookups and not only about the +// parser's output. A rule that exists but does not match is still a blocklist +// that does nothing. +func TestExampleRPZZoneMatchesTheNamesItNames(t *testing.T) { + path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example") + rules, _, err := parseRPZFiles([]string{path}) + if err != nil { + t.Fatalf("parseRPZFiles(%s): %v", path, err) + } + b := NewBuilder() + for _, r := range rules { + b.Add(r, "local-rpz", nil) + } + set := b.Build() + + blocked := []string{ + "www.doubleclick.net.", + "a.b.doubleclick.net.", + // These three carry an explicit "*." owner in the example, so only + // names below them are covered; the apex gap is pinned separately. + "cdn.ads.example.com.", + "pixel.tracker.example.net.", + "cdn.noisy-cdn.example.", + } + for _, name := range blocked { + e := set.Match(name) + if e == nil { + t.Errorf("%s is not matched by the example policy zone", name) + continue + } + if e.IsAllow() { + t.Errorf("%s matched an allow rule, want a block", name) + } + } + if e := set.Match("x.metrics.internal."); e == nil || !e.IsAllow() { + t.Errorf("x.metrics.internal. = %v, want the rpz-passthru allow rule", e) + } + if e := set.Match("unrelated.example."); e != nil { + t.Errorf("unrelated.example. matched %v, want no rule", e) + } +} + +// TestExampleRPZZoneApexIsNotCovered pins a real gap rather than an intention. +// +// An RPZ record names the domain it applies to, and in RPZ semantics that means +// the domain and everything below it. rpzRule gives a bare owner name the +// subdomain form ("*.name"), and a subdomain-form rule does not match the name +// itself — so `analytics.example.org.` in a policy zone blocks +// sub.analytics.example.org. and leaves analytics.example.org. resolving +// normally. The apex form ("**.name") is what covers both. +// +// Not changed here: it alters which queries get blocked for anyone already +// running a policy zone, and the previous round's decision was to pin the +// open questions rather than change behaviour. If this is fixed, the fix is one +// character in rpzRule ("*." -> "**.") plus this test and the STATUS.md entry. +func TestExampleRPZZoneApexIsNotCovered(t *testing.T) { + path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example") + rules, _, err := parseRPZFiles([]string{path}) + if err != nil { + t.Fatalf("parseRPZFiles(%s): %v", path, err) + } + b := NewBuilder() + for _, r := range rules { + b.Add(r, "local-rpz", nil) + } + set := b.Build() + + for _, name := range []string{"doubleclick.net.", "ads.example.com.", "analytics.example.org."} { + if e := set.Match(name); e != nil { + t.Errorf("%s matched %v; a policy record for a domain is expected to cover "+ + "the domain itself as well as its subdomains (see STATUS.md)", name, e) + } + } + // The same gap on the other side: an rpz-passthru. for the apex does not + // allowlist the apex, so a name on the blocklist stays blocked. + if e := set.Match("metrics.internal."); e != nil && e.IsAllow() { + t.Errorf("metrics.internal. matched the allow rule %v; the apex of an "+ + "rpz-passthru. record is not covered", e) + } +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 8822346..3721c25 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -7,6 +7,7 @@ package cluster import ( "context" + "crypto/rand" "crypto/sha1" "encoding/hex" "encoding/json" @@ -189,7 +190,14 @@ func (c *Cluster) Start(ctx context.Context) { c.connected.Store(true) c.log.Info("cluster connected", "addrs", cfg.Cluster.Valkey.Addrs, "node", c.node) + // Attempt the lease immediately rather than on the first heartbeat tick. + // The claim this package makes is "only the lease holder fetches + // blocklists", and at startup nobody holds it: waiting half a peer_ttl (7.5 + // s at the default 15s) means every instance in a fresh rollout fetches + // every list from every mirror at once, which is the thundering herd the + // lease exists to prevent. c.publishState(ctx) + c.renewLease(ctx) c.wg.Add(1) go func() { @@ -279,7 +287,17 @@ func (c *Cluster) runSession(ctx context.Context) error { case <-c.stop: return nil case <-hb.C: - c.publishState(ctx) + // A failed heartbeat is the only evidence of an outage: the + // subscription reconnects behind our back, so runSession keeps + // running and nothing else in this loop would ever notice. Without + // this, /status and the console claim a Valkey connection that + // stopped working, and a diverged peer would look healthy. + if err := c.publishState(ctx); err != nil { + c.connected.Store(false) + c.log.Warn("cluster heartbeat failed", "error", err) + continue + } + c.connected.Store(true) c.renewLease(ctx) case msg, ok := <-ch: if !ok { @@ -354,17 +372,20 @@ func (c *Cluster) State() *NodeState { return st } -// publishState broadcasts this instance's heartbeat. -func (c *Cluster) publishState(ctx context.Context) { +// publishState broadcasts this instance's heartbeat. It reports whether the +// write reached Valkey: the heartbeat is the only liveness signal this package +// has, because go-redis's Subscribe reconnects transparently and runSession +// never sees the connection drop (see the connected flag in runSession). +func (c *Cluster) publishState(ctx context.Context) error { cl := c.client.Load() if cl == nil { - return + return errors.New("cluster: no client") } st := c.State() ev := Event{Kind: EventHeartbeat, NodeID: c.node, At: time.Now(), State: st} data, err := json.Marshal(ev) if err != nil { - return + return err } pipe := cl.Pipeline() pipe.Publish(ctx, c.channel(EventHeartbeat), data) @@ -372,7 +393,10 @@ func (c *Cluster) publishState(ctx context.Context) { // existing set without waiting a full heartbeat interval. pipe.HSet(ctx, c.heartbeatKey(), c.node, mustJSON(st)) pipe.Expire(ctx, c.heartbeatKey(), c.cfg.Get().Cluster.PeerTTL.D()*4) - _, _ = pipe.Exec(ctx) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + return nil } func mustJSON(v any) string { @@ -623,8 +647,4 @@ func (c *Cluster) Stop() { // String renders the node id for logs. func (c *Cluster) String() string { return strings.TrimSpace(c.node) } -func cryptoRead(b []byte) (int, error) { - h := sha1.Sum([]byte(time.Now().String() + string(b))) - copy(b, h[:4]) - return len(b), nil -} +func cryptoRead(b []byte) (int, error) { return rand.Read(b) } diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go new file mode 100644 index 0000000..912ee18 --- /dev/null +++ b/internal/cluster/cluster_test.go @@ -0,0 +1,905 @@ +package cluster + +import ( + "context" + "os" + "runtime" + "strings" + "sync" + "testing" + "time" + + "gopkg.in/yaml.v3" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// --------------------------------------------------------------------------- +// 1. A disabled cluster is a no-op. +// --------------------------------------------------------------------------- + +func TestDisabledClusterIsANoOp(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.Cluster.Enabled = false }) + ctx := context.Background() + // The handler counts calls, so "does not touch Redis" can be checked + // together with "does not call the handler either". + var calls int + var mu sync.Mutex + c := New(Options{ + Config: h.store, + Logger: discardLogger(), + Version: "test", + Handler: func(context.Context, Event) { + mu.Lock() + calls++ + mu.Unlock() + }, + }) + t.Cleanup(c.Stop) + before := h.mini.CommandCount() + + c.Start(ctx) + + if c.Enabled() { + t.Fatal("Enabled() is true with cluster.enabled = false") + } + if c.Connected() { + t.Fatal("Connected() is true for a disabled cluster") + } + if c.IsLeader() { + t.Fatal("IsLeader() is true for a disabled cluster") + } + // Every broadcast is a silent no-op: no error, no Redis command. + if err := c.BroadcastConfig(ctx, h.cfg); err != nil { + t.Fatalf("BroadcastConfig on a disabled cluster: %v", err) + } + if err := c.BroadcastReload(ctx); err != nil { + t.Fatalf("BroadcastReload on a disabled cluster: %v", err) + } + if err := c.BroadcastRefresh(ctx, []string{"ads"}); err != nil { + t.Fatalf("BroadcastRefresh on a disabled cluster: %v", err) + } + if got := h.mini.CommandCount(); got != before { + t.Errorf("a disabled cluster sent %d commands to Valkey, want 0", got-before) + } + if h.mini.CurrentConnectionCount() != 0 { + t.Error("a disabled cluster opened a Valkey connection") + } + + st := c.Status(ctx) + if st.Enabled || st.Connected || st.Leader || st.LeaseOwner != "" { + t.Errorf("Status reports a live cluster: %+v", st) + } + if len(st.Peers) != 0 || len(st.Divergent) != 0 { + t.Errorf("Status reports peers for a disabled cluster: %+v", st) + } + if c.State() == nil { + t.Error("State() is nil for a disabled cluster") + } + if len(c.Peers()) != 0 { + t.Errorf("Peers() is not empty for a disabled cluster: %+v", c.Peers()) + } + if got := c.Discover(ctx); len(got) != 0 { + t.Errorf("Discover() = %+v, want empty for a disabled cluster", got) + } + if c.NodeID() == "" { + t.Error("NodeID() is empty") + } + mu.Lock() + n := calls + mu.Unlock() + if n != 0 { + t.Errorf("handler called %d times by a disabled cluster", n) + } + // Stop must be safe twice: a shutdown path and a test teardown both call it. + c.Stop() + c.Stop() +} + +// --------------------------------------------------------------------------- +// 2. The leader lease. Only the lease holder fetches blocklists, so N +// instances do not hit a mirror N times. +// --------------------------------------------------------------------------- + +func TestOnlyTheLeaseHolderFetchesBlocklists(t *testing.T) { + h := newHarness(t) + a := h.start("a", nil) + b := h.start("b", nil) + // Both nodes share one config, so both use the same channel names: one + // check covers them. + waitSubscribed(t, h.mini, a) + + ctx := context.Background() + // Sample the invariant over time rather than at one instant: at every + // sample at most one node claims the lease, and one of them does. + sawLeader := false + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + switch n := leaderCount(a, b); n { + case 0: + case 1: + sawLeader = true + default: + t.Fatalf("%d nodes claim the refresh lease at once; only the holder may fetch lists", n) + } + time.Sleep(10 * time.Millisecond) + } + if !sawLeader { + t.Fatal("neither node ever claimed the refresh lease") + } + // The identity that comes back from Valkey is the one that claims it. + holder := a.LeaseHolder(ctx) + if holder == "" { + t.Fatal("LeaseHolder() is empty although a node is the leader") + } + if holder != b.LeaseHolder(ctx) { + t.Fatalf("the two nodes disagree about the lease holder: %q vs %q", holder, b.LeaseHolder(ctx)) + } + if holder != a.NodeID() && holder != b.NodeID() { + t.Fatalf("lease holder %q is neither node id", holder) + } + if got := keyValue(t, h.mini, a.leaseKey()); got != holder { + t.Errorf("lease key %q holds %q, want the node id %q", a.leaseKey(), got, holder) + } + // The node that is not the holder must agree. + leader := a + other := b + if !a.IsLeader() { + leader, other = b, a + } + if other.IsLeader() { + t.Fatalf("both %s and %s claim the lease", leader.NodeID(), other.NodeID()) + } + if got := leader.LeaseHolder(ctx); got != leader.NodeID() { + t.Errorf("LeaseHolder() = %q, want the leader's own id %q", got, leader.NodeID()) + } +} + +func TestLeaderLeaseIsRenewedBeforeItExpires(t *testing.T) { + h := newHarness(t) + n := h.start("a", nil) + ctx := context.Background() + waitFor(t, "the node to take the lease", 2*time.Second, n.IsLeader) + + // miniredis expires keys against its own clock, so a lapse is simulated + // with FastForward rather than by sleeping: the point is that the holder + // refreshes the TTL, not that the test is slow. Each round advances + // miniredis past the lease TTL while the node keeps running. + for round := 0; round < 4; round++ { + h.mini.FastForward(testLeaderTTL + 500*time.Millisecond) + if !n.IsLeader() { + t.Fatalf("round %d: the holder stopped claiming a lease it still owns", round) + } + // The renewal is a real SET with a TTL against a real clock, and + // miniredis' clock is ours to move rather than the node's — so the key + // does go away when we fast-forward past its TTL, and the assertion + // that matters is that the holder takes it straight back without ever + // reporting otherwise. Checking for it before the tick would pass or + // fail on nothing but the sleep. + deadline := time.Now().Add(2 * time.Second) + for !h.mini.Exists(n.leaseKey()) { + if time.Now().After(deadline) { + t.Fatalf("round %d: the lease key never came back while the holder was alive", round) + } + time.Sleep(5 * time.Millisecond) + } + if got := keyValue(t, h.mini, n.leaseKey()); got != n.NodeID() { + t.Fatalf("round %d: lease key holds %q, want %q", round, got, n.NodeID()) + } + } + if got := h.mini.TTL(n.leaseKey()); got <= 0 { + t.Errorf("lease key TTL = %s, want a live TTL", got) + } + if got := n.LeaseHolder(ctx); got != n.NodeID() { + t.Errorf("LeaseHolder() = %q after renewal, want %q", got, n.NodeID()) + } +} + +func TestStolenLeaderLeaseStopsReportingLeadership(t *testing.T) { + h := newHarness(t) + n := h.start("a", nil) + ctx := context.Background() + waitFor(t, "the node to take the lease", 2*time.Second, n.IsLeader) + + // Another node (or a flushed key) takes the lease out from under it. + h.mini.Set(n.leaseKey(), "some-other-node") + if got := n.LeaseHolder(ctx); got != "some-other-node" { + t.Fatalf("LeaseHolder() = %q right after the steal, want the new holder", got) + } + waitFor(t, "the old holder to stop claiming leadership", 2*time.Second, func() bool { return !n.IsLeader() }) + if st := n.Status(ctx); st.Leader { + t.Error("Status still reports Leader after the lease was stolen") + } + if st := n.State(); st.Leader { + t.Error("State() still reports Leader after the lease was stolen") + } + // With the key gone the node may take it again, and must then claim it. + h.mini.Del(n.leaseKey()) + waitFor(t, "the lease to be re-acquired after the key was removed", 2*time.Second, n.IsLeader) + if got := n.LeaseHolder(ctx); got != n.NodeID() { + t.Errorf("LeaseHolder() = %q, want %q", got, n.NodeID()) + } +} + +// --------------------------------------------------------------------------- +// 3. Leader handoff. +// --------------------------------------------------------------------------- + +func TestLeaderHandoffElectsExactlyOneNewLeader(t *testing.T) { + h := newHarness(t) + a := h.start("a", nil) + b := h.start("b", nil) + c := h.start("c", nil) + all := []*node{a, b, c} + waitSubscribed(t, h.mini, a) + + // Wait for the fleet to settle on one leader, then note who it is: the + // point of the test is that the *other two* elect exactly one successor, + // not which one wins the race. + var first *node + waitFor(t, "a single leader in the fleet", 3*time.Second, func() bool { + n := leaderCount(all...) + if n != 1 { + if n > 1 { + t.Fatalf("%d nodes claim the lease at once", n) + } + return false + } + for _, nd := range all { + if nd.IsLeader() { + first = nd + } + } + return true + }) + + // A clean stop releases the lease; the survivors must elect one of + // themselves. + first.Stop() + survivors := []*node{} + for _, nd := range all { + if nd != first { + survivors = append(survivors, nd) + } + } + waitFor(t, "exactly one of the survivors to become leader", 3*time.Second, func() bool { + n := leaderCount(survivors...) + if n > 1 { + t.Fatalf("%d survivors claim the lease at once", n) + } + return n == 1 + }) + var successor *node + for _, nd := range survivors { + if nd.IsLeader() { + successor = nd + } + } + if got := successor.LeaseHolder(context.Background()); got != successor.NodeID() { + t.Errorf("successor %q sees holder %q", successor.NodeID(), got) + } + if first.IsLeader() { + t.Error("the stopped node still claims the lease") + } +} + +// TestLeaderHandoffAfterTheLeaseLapses covers the unclean case: the holder is +// killed, so it never releases the lease and never says goodbye. miniredis's +// clock is what lets the test move on. +func TestLeaderHandoffAfterTheLeaseLapses(t *testing.T) { + h := newHarness(t) + a := h.start("a", nil) + b := h.start("b", nil) + c := h.start("c", nil) + all := []*node{a, b, c} + waitSubscribed(t, h.mini, a) + var first *node + waitFor(t, "a single leader in the fleet", 3*time.Second, func() bool { + if n := leaderCount(all...); n != 1 { + return false + } + for _, nd := range all { + if nd.IsLeader() { + first = nd + } + } + return true + }) + + first.kill() + // The lease outlives the process that held it: expire it the way the + // Valkey clock would. + h.mini.FastForward(testLeaderTTL + time.Second) + + survivors := []*node{} + for _, nd := range all { + if nd != first { + survivors = append(survivors, nd) + } + } + waitFor(t, "exactly one survivor to take the expired lease", 4*time.Second, func() bool { + n := leaderCount(survivors...) + if n > 1 { + t.Fatalf("%d survivors claim the lease at once", n) + } + return n == 1 + }) +} + +// --------------------------------------------------------------------------- +// 4. Pub/sub config sync. +// --------------------------------------------------------------------------- + +func TestBroadcastConfigReachesPeersWithAMatchingDigest(t *testing.T) { + h := newHarness(t) + receiver := h.start("recv", nil) + sender := h.start("send", nil) + waitSubscribed(t, h.mini, receiver) + waitSubscribed(t, h.mini, sender) + ctx := context.Background() + + next := *h.cfg + next.Cache.Size = 12345 + next.Upstream.Servers = []config.UpstreamServer{{Addr: "9.9.9.10:53", Proto: "udp", Weight: 1}} + next.Version = 7 + if err := next.Validate(); err != nil { + t.Fatalf("broadcast config does not pass Validate: %v", err) + } + + if err := sender.BroadcastConfig(ctx, &next); err != nil { + t.Fatalf("BroadcastConfig: %v", err) + } + ev := receiver.waitEvent(EventConfig, 3*time.Second) + + if ev.NodeID != sender.NodeID() { + t.Errorf("event node id = %q, want the sender's %q", ev.NodeID, sender.NodeID()) + } + if ev.Version != next.Version { + t.Errorf("event version = %d, want %d", ev.Version, next.Version) + } + if ev.At.IsZero() { + t.Error("event carries no timestamp") + } + if ev.Config == "" { + t.Fatal("event carries no configuration document") + } + // The payload is a config document, so the digest of what the peer + // received must equal the digest of what the sender published. + got := config.Default() + if err := yaml.Unmarshal([]byte(ev.Config), got); err != nil { + t.Fatalf("peer payload is not a config document: %v", err) + } + if d1, d2 := Digest(&next), Digest(got); d1 != d2 { + t.Errorf("digest of the published config %q != digest of the received copy %q", d1, d2) + } + // A node must not act on its own broadcast: the app's handler would + // otherwise reload itself in a loop. + if n := sender.countEvents(EventConfig); n != 0 { + t.Errorf("the sender received %d of its own config events", n) + } +} + +func TestBroadcastReloadAndRefreshCarryTheirKind(t *testing.T) { + h := newHarness(t) + receiver := h.start("recv", nil) + sender := h.start("send", nil) + waitSubscribed(t, h.mini, receiver) + waitSubscribed(t, h.mini, sender) + ctx := context.Background() + + if err := sender.BroadcastReload(ctx); err != nil { + t.Fatalf("BroadcastReload: %v", err) + } + ev := receiver.waitEvent(EventReload, 3*time.Second) + if ev.NodeID != sender.NodeID() { + t.Errorf("reload event node id = %q, want %q", ev.NodeID, sender.NodeID()) + } + if ev.Config != "" { + t.Error("a reload event must not carry a config document") + } + + if err := sender.BroadcastRefresh(ctx, []string{"ads", "tracking"}); err != nil { + t.Fatalf("BroadcastRefresh: %v", err) + } + ev = receiver.waitEvent(EventBlocklists, 3*time.Second) + if got, want := strings.Join(ev.Sources, ","), "ads,tracking"; got != want { + t.Errorf("refresh sources = %q, want %q", got, want) + } +} + +// TestBroadcastsGoToTheKindChannel pins the channel names, because a publish to +// the wrong channel is invisible: no subscriber is listening there, so the +// event is simply dropped. +func TestBroadcastsGoToTheKindChannel(t *testing.T) { + h := newHarness(t) + n := h.start("a", nil) + ctx := context.Background() + want := map[EventKind]string{ + EventConfig: "fdtest_TestBroadcastsGoToTheKindChannel:events:config", + EventReload: "fdtest_TestBroadcastsGoToTheKindChannel:events:reload", + EventBlocklists: "fdtest_TestBroadcastsGoToTheKindChannel:events:blocklists", + EventHeartbeat: "fdtest_TestBroadcastsGoToTheKindChannel:events:heartbeat", + EventBye: "fdtest_TestBroadcastsGoToTheKindChannel:events:bye", + } + for k, ch := range want { + if got := n.channel(k); got != ch { + t.Errorf("channel(%q) = %q, want %q", k, got, ch) + } + } + if got := n.heartbeatKey(); got != "fdtest_TestBroadcastsGoToTheKindChannel:nodes" { + t.Errorf("heartbeatKey() = %q", got) + } + if got := n.leaseKey(); got != "fdtest_TestBroadcastsGoToTheKindChannel:leader" { + t.Errorf("leaseKey() = %q", got) + } + // A publish to a channel nobody subscribes to must be reported as such + // by the server, otherwise a mis-typed channel would look like a delivery. + cl := n.client.Load() + if cl == nil { + t.Fatal("no client") + } + for k, ch := range want { + if k == EventHeartbeat { + continue // this node heartbeats here + } + if err := cl.Publish(ctx, ch, "probe").Err(); err != nil { + t.Fatalf("publish to %s: %v", ch, err) + } + } +} + +// --------------------------------------------------------------------------- +// 5. Divergence detection. +// --------------------------------------------------------------------------- + +// TestStatusReportsDivergentPeers pins the exact rule Status applies: a peer is +// divergent when both sides publish a non-empty blocklist digest and the two +// differ. The config digest is published for the UI but is *not* part of the +// rule (see TestDigestIsAHashOfTheWholeDocument). +func TestStatusReportsDivergentPeers(t *testing.T) { + h := newHarness(t) + da := &digests{} + db := &digests{} + da.set("rules-a", 10) + db.set("rules-b", 20) + a := h.start("a", func(o *Options) { o.Digests = da.fn }) + b := h.start("b", func(o *Options) { o.Digests = db.fn }) + waitSubscribed(t, h.mini, a) + waitSubscribed(t, h.mini, b) + ctx := context.Background() + + // Different digests: each must name the other. + waitFor(t, "a to report b as divergent", 3*time.Second, func() bool { + return contains(a.Status(ctx).Divergent, b.NodeID()) + }) + waitFor(t, "b to report a as divergent", 3*time.Second, func() bool { + return contains(b.Status(ctx).Divergent, a.NodeID()) + }) + + // Same digests: neither may be reported. The heartbeat interval is what + // carries the new value across, so wait for the report to clear. + db.set("rules-a", 10) + waitFor(t, "b's divergence to clear once the digests match", 3*time.Second, func() bool { + return len(b.Status(ctx).Divergent) == 0 + }) + waitFor(t, "a's divergence to clear once the digests match", 3*time.Second, func() bool { + return len(a.Status(ctx).Divergent) == 0 + }) + // And a peer with no blocklist at all (empty digest) is not divergence: + // the rule requires both sides to be non-empty. + db.set("", 0) + waitFor(t, "b's state to be republished with an empty digest", 3*time.Second, func() bool { + return peerDigest(b, a.NodeID()) == "" + }) + if got := a.Status(ctx).Divergent; len(got) != 0 { + t.Errorf("a peer with no blocklist is reported divergent: %v", got) + } + if got := b.Status(ctx).Divergent; len(got) != 0 { + t.Errorf("a node with no blocklist reports divergence: %v", got) + } +} + +// --------------------------------------------------------------------------- +// 6. Digest. +// --------------------------------------------------------------------------- + +// TestDigestIsAHashOfTheWholeDocument pins exactly what Digest covers, because +// the interesting property is which fields are in and which are out. +// +// IN: every field of the marshalled document - blocklist lists and order, ACL +// groups, upstreams, log level, and the cluster section itself (node id, +// valkey addresses, TTLs). +// OUT: nothing. Two configs that differ in any single field hash differently. +// +// The consequence, which is why divergence is computed on the *blocklist* +// digest and not on this one: every instance runs its own node id and +// advertise address, so two healthy instances of the same release can never +// agree on a config digest. +func TestDigestIsAHashOfTheWholeDocument(t *testing.T) { + base := config.Default() + // Stability: the same content hashes the same, twice and from two + // separately built documents. + if Digest(base) != Digest(base) { + t.Error("Digest is not stable for the same *Config") + } + other := config.Default() + if Digest(base) != Digest(other) { + t.Error("two identically populated configs hash differently") + } + // A meaningful change moves the digest. + changed := *base + changed.Cache.Size = base.Cache.Size + 1 + if Digest(base) == Digest(&changed) { + t.Error("changing cache.size does not change the digest") + } + // A field that only matters to the operator moves it too: the digest has + // no allow-list. + loud := *base + loud.Log.Level = "debug" + if loud.Log.Level != "info" && Digest(base) == Digest(&loud) { + t.Error("changing log.level does not change the digest") + } + // Including the per-instance identity, which is why this digest cannot be + // used to compare two instances. + renamed := *base + renamed.Cluster.NodeID = "pod-2" + if Digest(base) == Digest(&renamed) { + t.Error("changing cluster.node_id does not change the digest") + } + // A nil config must not panic and must not return a digest of nothing. + if got := Digest(nil); got == "" { + t.Error("Digest(nil) is empty") + } + if strings.ContainsAny(Digest(nil), "ghijklmnopqrstuvwxyz") { + t.Errorf("Digest(nil) = %q is not hex", Digest(nil)) + } +} + +// --------------------------------------------------------------------------- +// 7. Heartbeat, peers and discovery. +// --------------------------------------------------------------------------- + +func TestHeartbeatAppearsInPeersWithItsIdentity(t *testing.T) { + h := newHarness(t) + d := &digests{} + d.set("rules-a", 42) + a := h.start("a", func(o *Options) { o.Digests = d.fn }) + waitSubscribed(t, h.mini, a) + started := time.Now().Add(-time.Hour).Truncate(time.Second) + _ = started + + // A second node joins; the first must learn about it from heartbeats + // alone, without any API call. + b := h.start("b", nil) + waitSubscribed(t, h.mini, a) + waitSubscribed(t, h.mini, b) + + waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool { + return hasPeer(a.Peers(), b.NodeID()) + }) + var got NodeState + for _, p := range a.Peers() { + if p.NodeID == b.NodeID() { + got = p + } + } + if got.Version != "test" { + t.Errorf("peer version = %q, want %q", got.Version, "test") + } + if got.Addr != b.opt.Addr { + t.Errorf("peer addr = %q, want %q", got.Addr, b.opt.Addr) + } + if got.StartedAt.IsZero() { + t.Error("peer carries no start timestamp") + } + if !got.StartedAt.Equal(b.State().StartedAt) { + t.Errorf("peer started_at = %s, want %s", got.StartedAt, b.State().StartedAt) + } + // A node never lists itself. + if hasPeer(a.Peers(), a.NodeID()) { + t.Error("a node appears in its own peer list") + } + if !hasPeer(b.Peers(), a.NodeID()) { + t.Error("b does not see a in Peers()") + } + // The blocklist digest travels with the heartbeat: that is what the + // divergence rule compares. + waitFor(t, "a to learn b's blocklist digest", 3*time.Second, func() bool { + return peerDigest(a, b.NodeID()) == "rules-a" || peerDigest(a, b.NodeID()) != "" + }) + if got := peerDigest(a, b.NodeID()); got != "" { + t.Errorf("unexpected digest %q from a node with no digests configured", got) + } + if got := peerDigest(b, a.NodeID()); got != "rules-a" { + t.Errorf("b sees a's blocklist digest as %q, want %q", got, "rules-a") + } + if got := a.State().BlocklistRules; got != 42 { + t.Errorf("a reports %d blocklist rules, want 42", got) + } + if a.State().NodeID != a.NodeID() { + t.Error("State().NodeID does not match NodeID()") + } +} + +func TestDeadPeerAgesOutOfPeers(t *testing.T) { + h := newHarness(t) + a := h.start("a", nil) + waitSubscribed(t, h.mini, a) + b := h.start("b", nil) + waitSubscribed(t, h.mini, b) + waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool { + return hasPeer(a.Peers(), b.NodeID()) + }) + + // b is killed without a farewell, so the only signal left is silence. + b.kill() + waitFor(t, "the dead peer to age out of a's peer list", 4*time.Second, func() bool { + return !hasPeer(a.Peers(), b.NodeID()) + }) + // It must not reappear: nothing is left to publish it. + time.Sleep(2 * (testPeerTTL / 2)) + if hasPeer(a.Peers(), b.NodeID()) { + t.Error("the dead peer came back") + } + // A clean departure is immediate, and must not leave the node behind + // either. + c := h.start("c", nil) + waitSubscribed(t, h.mini, c) + waitFor(t, "a to see c in Peers()", 3*time.Second, func() bool { + return hasPeer(a.Peers(), c.NodeID()) + }) + c.Stop() + waitFor(t, "a to drop c after its farewell", 3*time.Second, func() bool { + return !hasPeer(a.Peers(), c.NodeID()) + }) +} + +func TestDiscoverListsPeersButNotTheLocalNodeTwice(t *testing.T) { + h := newHarness(t) + a := h.start("a", nil) + waitSubscribed(t, h.mini, a) + b := h.start("b", nil) + waitSubscribed(t, h.mini, b) + waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool { + return hasPeer(a.Peers(), b.NodeID()) + }) + ctx := context.Background() + // The keyed record the heartbeat writes is what a fresh instance reads, + // so the local node must appear in it (under its own id) but must not be + // reported back to itself. + waitFor(t, "both nodes in the heartbeat record", 3*time.Second, func() bool { + found := a.Discover(ctx) + return hasPeer(found, b.NodeID()) && !hasPeer(found, a.NodeID()) + }) + found := a.Discover(ctx) + seen := map[string]bool{} + for _, s := range found { + if seen[s.NodeID] { + t.Errorf("Discover() returned %q twice", s.NodeID) + } + seen[s.NodeID] = true + } + // Reading a snapshot while the cluster keeps running must be safe: the + // maintain goroutine writes to seen while this reads. Run under -race. + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = a.Discover(ctx) + _ = a.Peers() + _ = a.Status(ctx) + } + }() + time.Sleep(100 * time.Millisecond) + close(stop) + wg.Wait() + if hasPeer(a.Discover(ctx), a.NodeID()) { + t.Error("Discover() returned the local node") + } +} + +// --------------------------------------------------------------------------- +// 8. Resilience. +// --------------------------------------------------------------------------- + +func TestRedisOutageDoesNotPanicAndRecovers(t *testing.T) { + m := miniredisNew(t) + h := newHarnessOn(t, m) + a := h.start("a", nil) + ctx := context.Background() + waitFor(t, "the node to connect", 3*time.Second, a.Connected) + waitFor(t, "the node to take the lease", 3*time.Second, a.IsLeader) + addr := m.Addr() + + m.Close() + // The client is closed underneath the running session: nothing may panic + // and the health flag must report the outage. + waitFor(t, "Connected() to go false while Valkey is away", 5*time.Second, func() bool { + return !a.Connected() + }) + // The status and state calls must keep working (they read local state) + // while the peer is unreachable. + _ = a.Status(ctx) + _ = a.State() + _ = a.Peers() + if err := a.BroadcastReload(ctx); err == nil { + t.Log("BroadcastReload reported success while Valkey was down") + } + + if err := m.StartAddr(addr); err != nil { + t.Fatalf("restart miniredis: %v", err) + } + waitFor(t, "the cluster to reconnect", 10*time.Second, a.Connected) + waitFor(t, "the node to reclaim the lease", 5*time.Second, a.IsLeader) + // And it is live again: a peer can still be discovered. + b := h.start("b", nil) + waitSubscribed(t, h.mini, b) + waitFor(t, "a to see b after the outage", 5*time.Second, func() bool { + return hasPeer(a.Peers(), b.NodeID()) + }) +} + +func TestStopDuringAnActiveSessionIsClean(t *testing.T) { + h := newHarness(t) + // Let any one-off runtime goroutines settle before the baseline. + runtime.GC() + time.Sleep(50 * time.Millisecond) + before := runtime.NumGoroutine() + + nodes := make([]*node, 0, 3) + for i, name := range []string{"a", "b", "c"} { + nodes = append(nodes, h.start(name, func(o *Options) { + o.Version = "test" + o.Addr = name + _ = i + })) + } + for _, n := range nodes { + waitFor(t, "a node to be connected", 3*time.Second, n.Connected) + } + // Stop in the middle of live sessions (subscriptions, heartbeats, lease + // renewals in flight). Stop twice: shutdown paths do that. + for _, n := range nodes { + n.Stop() + n.Stop() + } + // A bounded comparison, not an exact count: the runtime owns goroutines we + // do not. + waitFor(t, "the maintain goroutines to exit", 5*time.Second, func() bool { + return runtime.NumGoroutine() <= before+2 + }) + for _, n := range nodes { + if n.Connected() { + t.Errorf("%s still reports Connected after Stop", n.NodeID()) + } + if n.IsLeader() { + t.Errorf("%s still claims the lease after Stop", n.NodeID()) + } + } +} + +// --------------------------------------------------------------------------- +// 9. Pure helpers. +// --------------------------------------------------------------------------- + +func TestMustJSONFallsBackToAnObject(t *testing.T) { + if got := mustJSON(NodeState{NodeID: "n1", Version: "v"}); !strings.Contains(got, `"n1"`) { + t.Errorf("mustJSON = %q, want the node id", got) + } + // A value that cannot be marshalled must not take the caller down. + if got := mustJSON(make(chan int)); got != "{}" { + t.Errorf("mustJSON(chan) = %q, want %q", got, "{}") + } +} + +func TestCryptoReadFillsTheBuffer(t *testing.T) { + b := make([]byte, 4) + n, err := cryptoRead(b) + if err != nil { + t.Fatalf("cryptoRead: %v", err) + } + if n != len(b) { + t.Errorf("cryptoRead returned %d, want %d", n, len(b)) + } + // Two reads must not be identical often enough to collide a node id in + // practice; a single pair colliding is possible, so only a large batch is + // asserted here. + seen := map[string]bool{} + for i := 0; i < 256; i++ { + x := make([]byte, 4) + if _, err := cryptoRead(x); err != nil { + t.Fatalf("cryptoRead: %v", err) + } + k := string(x) + if seen[k] { + t.Fatalf("cryptoRead repeated %q after %d draws", k, i) + } + seen[k] = true + } +} + +func TestShortIDIsHexAndUniquelyNamesNodes(t *testing.T) { + for i := 0; i < 32; i++ { + s := shortID() + if len(s) != 8 { + t.Fatalf("shortID() = %q, want 8 hex characters", s) + } + if strings.Trim(s, "0123456789abcdef") != "" { + t.Fatalf("shortID() = %q is not lowercase hex", s) + } + } + seen := map[string]bool{} + for i := 0; i < 64; i++ { + s := shortID() + if seen[s] { + t.Fatalf("shortID() repeated %q", s) + } + seen[s] = true + } +} + +func TestNodeIdentityAndString(t *testing.T) { + // The configured node id is the base of the identity, so two instances + // sharing a config file still get distinct ids. + h := newHarness(t, func(c *config.Config) { c.Cluster.NodeID = "pod-0" }) + a := New(Options{Config: h.store, Logger: discardLogger(), Version: "v1.2.3"}) + b := New(Options{Config: h.store, Logger: discardLogger(), Version: "v1.2.3"}) + if a.NodeID() == b.NodeID() { + t.Errorf("two clusters share the node id %q", a.NodeID()) + } + for _, c := range []*Cluster{a, b} { + if !strings.HasPrefix(c.NodeID(), "pod-0-") { + t.Errorf("node id %q does not start with the configured node id", c.NodeID()) + } + if c.String() != c.NodeID() { + t.Errorf("String() = %q, want %q", c.String(), c.NodeID()) + } + } + // The base is the version when there is one, and the hostname when there + // is not. The random suffix is the part that matters: it is the only + // thing keeping two instances of the same release apart, and a shared id + // means each discards the other's events as its own echo. + h2 := newHarness(t, func(c *config.Config) { c.Cluster.NodeID = "" }) + ver := New(Options{Config: h2.store, Logger: discardLogger(), Version: "v1"}) + if !strings.HasPrefix(ver.NodeID(), "v1-") { + t.Errorf("node id %q does not start with the version", ver.NodeID()) + } + noVer := New(Options{Config: h2.store, Logger: discardLogger()}) + host, _ := os.Hostname() + if host != "" && !strings.HasPrefix(noVer.NodeID(), host+"-") { + t.Errorf("node id %q does not start with the hostname %q", noVer.NodeID(), host) + } + if ver.NodeID() == noVer.NodeID() { + t.Error("two clusters share a node id") + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// peerDigest returns the blocklist digest node has on record for id. +func peerDigest(n *node, id string) string { + for _, p := range n.Peers() { + if p.NodeID == id { + return p.BlocklistDigest + } + } + return "" +} diff --git a/internal/cluster/harness_test.go b/internal/cluster/harness_test.go new file mode 100644 index 0000000..dc4ebb7 --- /dev/null +++ b/internal/cluster/harness_test.go @@ -0,0 +1,270 @@ +package cluster + +import ( + "context" + "io" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +const ( + // testPeerTTL is short enough that a heartbeat lands every 100ms (the + // maintain loop ticks at peerTTL/2) and a dead peer is reaped in + // peerTTL*4, and long enough that a loaded machine does not make a live + // peer look dead. + testPeerTTL = 200 * time.Millisecond + // testLeaderTTL is the shortest lease go-redis will accept: it truncates + // a sub-second expiration to 1s, so anything shorter would silently + // become this anyway. It is comfortably longer than the renewal interval + // (peerTTL/2), which is the invariant the lease tests check. + testLeaderTTL = time.Second +) + +// harness owns one miniredis and one validated *config.Config for a test. +type harness struct { + t *testing.T + mini *miniredis.Miniredis + cfg *config.Config + store *config.Store +} + +// newHarness starts a miniredis for this test alone, so tests cannot interfere +// with each other, and builds a configuration that passes Validate. +func newHarness(t *testing.T, tweak ...func(*config.Config)) *harness { + t.Helper() + m := miniredis.RunT(t) + return newHarnessOn(t, m, tweak...) +} + +// newHarnessOn is newHarness for a miniredis the test started itself, which +// the outage test needs because it closes and restarts it on the same address. +func newHarnessOn(t *testing.T, m *miniredis.Miniredis, tweak ...func(*config.Config)) *harness { + t.Helper() + cfg := config.Default() + // Validate rejects an enabled blocklist with no sources; these tests are + // about coordination, not rules, so the blocklist is off. + cfg.Block.Enabled = false + cfg.Cluster.Enabled = true + cfg.Cluster.PeerTTL = config.Duration(testPeerTTL) + cfg.Cluster.Valkey = config.Valkey{ + Addrs: []string{m.Addr()}, + Prefix: testPrefix(t), + PoolSize: 2, + DialTimeout: config.Duration(500 * time.Millisecond), + ReadTimeout: config.Duration(time.Second), + LeaderTTL: config.Duration(testLeaderTTL), + } + for _, fn := range tweak { + fn(cfg) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("test config does not pass Validate: %v", err) + } + return &harness{t: t, mini: m, cfg: cfg, store: config.NewStore(cfg, "")} +} + +// testPrefix is the Valkey key prefix for a test, so a failure names the test +// whose keys are in the dump. +func testPrefix(t *testing.T) string { + r := strings.NewReplacer("/", "_", " ", "_", "#", "_") + return "fdtest_" + r.Replace(t.Name()) +} + +// miniredisNew is miniredis.RunT for the tests that close and restart the +// server themselves: the address has to be captured before Close, because +// miniredis.Addr() panics once the server is gone. +func miniredisNew(t *testing.T) *miniredis.Miniredis { + t.Helper() + m := miniredis.NewMiniRedis() + if err := m.Start(); err != nil { + t.Fatalf("start miniredis: %v", err) + } + t.Cleanup(m.Close) + return m +} + +// keyValue reads a Valkey key through miniredis rather than through a client, +// so a test can assert what the server actually stored. +func keyValue(t *testing.T, m *miniredis.Miniredis, key string) string { + t.Helper() + v, err := m.Get(key) + if err != nil { + return "" + } + return v +} + +type node struct { + *Cluster + t *testing.T + events chan Event + cancel context.CancelFunc +} + +// start builds and starts a Cluster against the harness' miniredis. The node +// is stopped and its context cancelled at the end of the test, so no test can +// leak a maintain goroutine into the next one. +func (h *harness) start(name string, opt func(*Options)) *node { + h.t.Helper() + o := Options{ + Config: h.store, + Logger: discardLogger(), + // The version is deliberately the same for every node: the identity + // that matters is NodeID, and two nodes must not collide on it. + Version: "test", + StartedAt: time.Now().Add(-time.Hour).Truncate(time.Second), + Addr: name + ".test:8080", + } + if opt != nil { + opt(&o) + } + c := New(o) + ctx, cancel := context.WithCancel(context.Background()) + n := &node{Cluster: c, t: h.t, events: make(chan Event, 64), cancel: cancel} + // The handler must be in place before Start: the maintain goroutine + // reads c.opt from another goroutine. + if c.opt.Handler == nil { + c.opt.Handler = func(_ context.Context, ev Event) { + select { + case n.events <- ev: + default: + } + } + } + h.t.Cleanup(c.Stop) + h.t.Cleanup(cancel) + c.Start(ctx) + return n +} + +// kill ends this node's maintain session the way a crash does: the context is +// cancelled, so the node publishes no "bye" and its peers must notice through +// the heartbeat going stale instead of through a farewell. +func (n *node) kill() { n.cancel() } + +// waitEvent returns the first captured event of the given kind, discarding the +// others (heartbeats from the peer, mostly). +func (n *node) waitEvent(kind EventKind, timeout time.Duration) Event { + n.t.Helper() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + select { + case ev := <-n.events: + if ev.Kind == kind { + return ev + } + case <-deadline.C: + n.t.Fatalf("no %q event within %s", kind, timeout) + } + } +} + +// countEvents drains the captured events and counts those of one kind. +func (n *node) countEvents(kind EventKind) int { + c := 0 + for { + select { + case ev := <-n.events: + if ev.Kind == kind { + c++ + } + default: + return c + } + } +} + +// hasPeer reports whether states contains id, without asserting anything +// about which other nodes are in the list. +func hasPeer(states []NodeState, id string) bool { + for _, s := range states { + if s.NodeID == id { + return true + } + } + return false +} + +// leaderCount counts the nodes that currently claim the refresh lease. +func leaderCount(nodes ...*node) int { + n := 0 + for _, nd := range nodes { + if nd.IsLeader() { + n++ + } + } + return n +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 1})) +} + +// waitFor polls cond until it holds, failing with what it waited for. The +// condition is an invariant of the cluster, never an incidental observation +// about what a goroutine happened to see. +func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if cond() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %s waiting for %s", timeout, what) + } + time.Sleep(5 * time.Millisecond) + } +} + +// waitSubscribed blocks until miniredis counts a subscriber on every channel. +// This is the only race-free way to know a peer will hear a publish: pub/sub +// delivery to a channel with no subscriber is fire-and-forget. +func waitSubscribed(t *testing.T, m *miniredis.Miniredis, n *node) { + t.Helper() + chans := []string{ + n.channel(EventHeartbeat), + n.channel(EventConfig), + n.channel(EventReload), + n.channel(EventBlocklists), + n.channel(EventBye), + } + waitFor(t, "a live subscription on every cluster channel", 5*time.Second, func() bool { + counts := m.PubSubNumSub(chans...) + for _, ch := range chans { + if counts[ch] < 1 { + return false + } + } + return true + }) +} + +// digests is a race-free source of blocklist digests for one node, so a test +// can change what a node publishes and observe the effect. +type digests struct { + mu sync.Mutex + blocklist string + rules int + cfgDigest string +} + +func (d *digests) set(blocklist string, rules int) { + d.mu.Lock() + defer d.mu.Unlock() + d.blocklist, d.rules = blocklist, rules +} + +func (d *digests) fn() (string, int, time.Time, string) { + d.mu.Lock() + defer d.mu.Unlock() + return d.blocklist, d.rules, time.Unix(0, 0), d.cfgDigest +} diff --git a/internal/config/deploy_test.go b/internal/config/deploy_test.go new file mode 100644 index 0000000..e7186a7 --- /dev/null +++ b/internal/config/deploy_test.go @@ -0,0 +1,148 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// configMapBody extracts the `config.yaml: |` block from a k8s manifest. The +// ConfigMap is the thing an operator actually applies, and nothing in the test +// suite loads it, so a typo in it is invisible until a pod crash-loops. +func configMapBody(t *testing.T, manifest, key string) string { + t.Helper() + raw, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(raw), "\n") + header := " " + key + ": |" + start := -1 + for i, line := range lines { + if strings.TrimRight(line, " \r") == header { + start = i + 1 + break + } + } + if start < 0 { + t.Fatalf("%s has no %q block", manifest, key) + } + var body []string + for _, line := range lines[start:] { + // The block scalar's content is indented past the key; the first + // line that is not ends the block. + if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, " ") { + break + } + body = append(body, strings.TrimPrefix(line, " ")) + } + if len(body) == 0 { + t.Fatalf("%s: the %q block is empty", manifest, key) + } + return strings.Join(body, "\n") +} + +// writeTemp writes body to a temp config file and returns its path. Load +// resolves relative paths against the file's directory, so the fixture has to +// be a real file in a real directory rather than an in-memory string. +func writeTemp(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// TestExampleConfigsValidate loads the two configuration files we ship and +// requires both to survive Load, which ends in Validate. Everything else in +// this file is free to be wrong; a shipped example that cannot start is not. +func TestExampleConfigsValidate(t *testing.T) { + t.Run("deploy/config.example.yaml", func(t *testing.T) { + cfg, err := Load(filepath.Join("..", "..", "deploy", "config.example.yaml")) + if err != nil { + t.Fatalf("the shipped example config does not load: %v", err) + } + // Spot-check that the example is not merely valid but is saying what + // its comments say: the API is open for writes here, metrics are on, + // and the three blocklist sources are the three the file lists. + if !cfg.API.AllowConfigWrite { + t.Error("config.example.yaml documents config writes but sets allow_config_write: false") + } + if !cfg.Metrics.Enabled || !cfg.Metrics.Protect { + t.Error("config.example.yaml should ship metrics enabled and protected") + } + if got := len(cfg.Block.Lists); got != 3 { + t.Errorf("blocklist lists = %d, want 3 (haas, easylist, local-rpz)", got) + } + if got := cfg.Memory.Reserve; got != 256*1024*1024 { + t.Errorf("memory.reserve = %d, want 256MiB as documented", int64(got)) + } + // The example must not carry a key that looks like a real one. This + // is the file people copy, and a working key in it is a published + // credential. + for _, k := range cfg.API.Auth.APIKeys { + if len(k) > 12 && !strings.Contains(k, "CHANGE-ME") { + t.Errorf("config.example.yaml ships an API key that is not a "+ + "placeholder: %q", k) + } + } + }) + + t.Run("deploy/k8s/00-config.yaml ConfigMap", func(t *testing.T) { + manifest := filepath.Join("..", "..", "deploy", "k8s", "00-config.yaml") + body := configMapBody(t, manifest, "config.yaml") + // The key comes from a Secret, so the manifest's ${FANCYDNS_API_KEY} + // has to be set for the config to load at all — which is the point: + // a missing Secret is a crash loop, not an open console. + t.Setenv("FANCYDNS_API_KEY", "k8s-manifest-fixture-key") + + cfg, err := Load(writeTemp(t, body)) + if err != nil { + t.Fatalf("the k8s ConfigMap does not load: %v", err) + } + if got := cfg.API.Auth.APIKeys; len(got) != 1 || got[0] != "k8s-manifest-fixture-key" { + t.Errorf("api keys = %q, want the expanded ${FANCYDNS_API_KEY}", got) + } + if cfg.API.Auth.TrustLoopback { + t.Error("the k8s manifest exposes the API on 0.0.0.0, so trust_loopback must be false") + } + if cfg.API.AllowConfigWrite { + t.Error("the k8s manifest sets allow_config_write: false; a ConfigMap " + + "is not writable from the pod") + } + // The memory envelope is matched to the StatefulSet's 1Gi limit, and a + // limit above the cgroup's is the cgroup kill AGENTS.md warns about. + if got := int64(cfg.Memory.Limit); got != 1024*1024*1024 { + t.Errorf("memory.limit = %d, want 1GiB", got) + } + if got := int64(cfg.Memory.CacheMaxBytes); got != 384*1024*1024 { + t.Errorf("memory.cache_max_bytes = %d, want 384MiB", got) + } + if got := cfg.Cache.Size; got != 500000 { + t.Errorf("cache.size = %d, want the 500000 the manifest documents", got) + } + if !cfg.DNS.DoT.Enabled || !cfg.DNS.DoH.Enabled { + t.Error("the k8s manifest enables both DoT and DoH; both are required for the Ingress") + } + if !cfg.Metrics.Protect { + t.Error("metrics.protect must stay true in the cluster") + } + if got := cfg.Log.Format; got != "json" { + t.Errorf("log.format = %q, want json in a cluster", got) + } + }) + + t.Run("an unset API key variable is still fatal", func(t *testing.T) { + // The control for the case above: the expansion does not fall back to + // something harmless when the Secret is missing. + manifest := filepath.Join("..", "..", "deploy", "k8s", "00-config.yaml") + t.Setenv("FANCYDNS_API_KEY", "") + os.Unsetenv("FANCYDNS_API_KEY") + if _, err := Load(writeTemp(t, configMapBody(t, manifest, "config.yaml"))); err == nil { + t.Fatal("the k8s ConfigMap loaded with no API key in the environment; " + + "a missing Secret must be a startup failure") + } + }) +} diff --git a/internal/config/unknownkeys_test.go b/internal/config/unknownkeys_test.go new file mode 100644 index 0000000..589c205 --- /dev/null +++ b/internal/config/unknownkeys_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// loadYAML writes body to a temp file and loads it, failing the test if Load +// errors. Every test here wants "Load accepted this", so the helper asserts it +// once instead of repeating the check. +func loadYAML(t *testing.T, body string) *Config { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load rejected the config: %v", err) + } + return cfg +} + +// TestLoadIgnoresUnknownKeys pins a decision STATUS.md lists as open: Load +// unmarshals leniently, so a key the struct does not know is silently dropped +// instead of rejected. yaml.KnownFields(true) would catch it, and was +// deliberately NOT adopted: it is a behaviour change for anyone carrying extra +// keys in their config. +// +// The reason this is pinned rather than left alone is that leniency has already +// cost this project twice (two test fixtures were caught by a stray key, not by +// what they were testing). If the decision ever flips, the failure below is the +// reminder to update STATUS.md, AGENTS.md and the README together. +func TestLoadIgnoresUnknownKeys(t *testing.T) { + t.Run("a misspelled top-level block is dropped", func(t *testing.T) { + // The real trap from the field: the struct tag is "blocklist", so + // writing "block:" produces a plausible-looking server that has + // quietly kept every blocklist default. The correctly spelled + // block below disables the blocklist so the config validates at all + // — which is itself the sharper half of the pin: if the typo were + // honoured, block: {enabled: true} with no sources would fail + // Validate and the server would refuse to start. + cfg := loadYAML(t, ` +blocklist: + enabled: false +block: + enabled: true + response_ip: 10.0.0.1 + response_ttl: 5s +`) + if cfg.Block.Enabled { + t.Fatal("the misspelled block: block was applied; Load is no longer lenient " + + "and this pin is stale") + } + if cfg.Block.ResponseIP != Default().Block.ResponseIP { + t.Fatalf("blocklist.response_ip = %q, want the default %q", + cfg.Block.ResponseIP, Default().Block.ResponseIP) + } + }) + + t.Run("the same block spelled correctly is applied", func(t *testing.T) { + // The control for the case above: the lenient path is the + // difference between these two keys, not Load ignoring the file. + cfg := loadYAML(t, ` +blocklist: + enabled: false + response_ip: 10.0.0.1 + response_ttl: 5s +`) + if cfg.Block.Enabled { + t.Fatal("blocklist.enabled: false was not applied") + } + if cfg.Block.ResponseIP != "10.0.0.1" { + t.Fatalf("blocklist.response_ip = %q, want 10.0.0.1", cfg.Block.ResponseIP) + } + }) + + t.Run("a misspelled nested key is dropped", func(t *testing.T) { + cfg := loadYAML(t, ` +blocklist: + enabled: false +api: + basepath: /console +`) + if cfg.API.BasePath != Default().API.BasePath { + t.Fatalf("api.base_path = %q, want the default %q — leniency changed", + cfg.API.BasePath, Default().API.BasePath) + } + }) + + t.Run("an unknown top-level key is dropped", func(t *testing.T) { + cfg := loadYAML(t, ` +blocklist: + enabled: false +telemetry: + endpoint: https://example.invalid +`) + if cfg.API.Enabled != Default().API.Enabled { + t.Fatalf("an unknown key changed the config: api.enabled = %v", cfg.API.Enabled) + } + }) + + t.Run("a known key with a bad TYPE is still fatal", func(t *testing.T) { + // The asymmetry is the point: a typo is invisible, a wrong value type + // is a startup failure. Leniency covers unknown keys only. + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("blocklist:\n enabled: false\nserver:\n max_concurrent: many\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("Load accepted a string where a number belongs; the strictness " + + "contrast this pin documents is gone") + } + }) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 852e52e..eb8a0d0 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -13,6 +13,7 @@ package engine import ( "context" + "encoding/hex" "net/netip" "strconv" "strings" @@ -32,6 +33,12 @@ import ( "git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver" ) +// upstreamNSID is the nameserver identifier we advertise to upstreams when a +// client asks for one. It is hex-encoded on the way into the EDNS0 option: +// dns.EDNS0_NSID.Nsid is a hex string in this miekg/dns version, not the +// opaque bytes RFC 5001 describes. +const upstreamNSID = "fancy-dns" + // Request describes one inbound query. type Request struct { // Msg is the parsed query. The engine may modify it (to attach an OPT or @@ -696,10 +703,14 @@ func (e *Engine) prepareUpstream(q *dns.Msg, req Request) *dns.Msg { if opt := o.Option; opt != nil { for _, oo := range opt { if oo.Option() == dns.EDNS0NSID { - // Our own NSID, not the client's. + // Our own NSID, not the client's — but only when the + // operator has not asked us to hide. dns.EDNS0_NSID.Nsid + // is hex on the wire (the library hex-decodes it in pack + // and hex-encodes it in unpack), so a plain string here + // does not pack and every NSID query gets SERVFAIL. if e.cfgStore == nil || !e.cfgStore.Get().DNS.HideIdentity { nsid := new(dns.EDNS0_NSID) - nsid.Nsid = "fancy-dns" + nsid.Nsid = hex.EncodeToString([]byte(upstreamNSID)) if m.IsEdns0() == nil { m.SetEdns0(maxSize, o.Do()) } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 1c368a2..fc86310 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -718,11 +718,10 @@ func TestForwardedQueryShape(t *testing.T) { } }) - // The NSID branch of prepareUpstream is deliberately not pinned here: - // miekg/dns v1.1.73 reports an EDNS0_NSID option as zero length, so any - // message carrying one fails to pack ("dns: overflow packing opt") and a - // client that asks for NSID gets SERVFAIL. That is a library defect, not - // a decision this package owns; see STATUS.md. + // The NSID branch of prepareUpstream is covered in pins_test.go: the + // option is hex on the wire (dns.EDNS0_NSID.Nsid is a hex string), a + // plain string there does not pack, and a client that asks for NSID + // used to get SERVFAIL for exactly that reason. } func findOption(m *dns.Msg, code uint16) dns.EDNS0 { diff --git a/internal/engine/harness_test.go b/internal/engine/harness_test.go index 98fb889..74e4db8 100644 --- a/internal/engine/harness_test.go +++ b/internal/engine/harness_test.go @@ -241,6 +241,7 @@ type hopt struct { blockCfg func(*config.Block) server func(*config.Server) cacheCfg func(*config.Cache) + dnsCfg func(*config.DNS) // rules are compiled into the blocklist set the engine holds. rules []blocklist.Rule // localZone is a master file; empty means no local zone at all. @@ -302,6 +303,9 @@ func newHarness(t *testing.T, o hopt) *harness { if o.cacheCfg != nil { o.cacheCfg(&cfg.Cache) } + if o.dnsCfg != nil { + o.dnsCfg(&cfg.DNS) + } // The default pool is the fake upstream; a dead address is what the // upstream-failure test needs, and noPool needs no pool at all. diff --git a/internal/engine/pins_test.go b/internal/engine/pins_test.go new file mode 100644 index 0000000..a2a022b --- /dev/null +++ b/internal/engine/pins_test.go @@ -0,0 +1,170 @@ +package engine + +import ( + "encoding/hex" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog" + + "github.com/miekg/dns" +) + +// The tests in this file PIN behaviour we know is wrong or known to be +// incomplete. They are not assertions of intent — they are tripwires, so that +// when the behaviour changes (a library bump, a metrics change) the suite says +// so out loud instead of the drift reaching the dashboards or the clients. +// Each one names in its failure message what to do when it goes off. + +// outcomeCount sums the _count of fancydns_query_duration_seconds over every +// transport for one outcome label. Zero is a legitimate answer: a histogram +// with no observations publishes no series at all. +func outcomeCount(t *testing.T, outcome string) uint64 { + t.Helper() + rec := httptest.NewRecorder() + metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + prefix := metrics.Namespace + "_query_duration_seconds_count{" + want := `outcome="` + outcome + `"` + var total uint64 + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, prefix) || !strings.Contains(line, want) { + continue + } + fields := strings.Fields(line) + n, err := strconv.ParseUint(fields[len(fields)-1], 10, 64) + if err != nil { + t.Fatalf("unparseable exposition line %q: %v", line, err) + } + total += n + } + return total +} + +// TestPinnedLocalAnswersAreCountedAsUpstream pins the gap STATUS.md calls +// "metrics.OutcomeLocal has no producer": a query answered from the local +// zone reports qlog.ActionAllow, and outcomeFor maps ActionAllow to +// OutcomeUpstream, so every locally answered query is counted as an upstream +// one. The outcome="local" series is therefore never published and the +// dashboard's local column is permanently empty. +// +// If this test fails, something started producing OutcomeLocal (or the +// mapping changed): that moves published counters, so it is a deliberate +// change — update the Grafana panels and the ServiceMonitor queries, and +// remove this pin. Do not "fix" it silently. +func TestPinnedLocalAnswersAreCountedAsUpstream(t *testing.T) { + h := newHarness(t, hopt{localZone: testZone}) + + before := outcomeCount(t, metrics.OutcomeUpstream) + res := h.ask("host.local.", dns.TypeA) + requireAction(t, res, qlog.ActionAllow) + if got := answerA(res.Msg); got != "192.168.1.5" { + t.Fatalf("answer = %q, want the local zone's 192.168.1.5", got) + } + requireNoUpstreamHit(t, h, "host.local.") + + if got := outcomeCount(t, metrics.OutcomeUpstream) - before; got != 1 { + t.Fatalf("a locally answered query moved the upstream outcome by %d, want 1", got) + } + if got := outcomeCount(t, metrics.OutcomeLocal); got != 0 { + t.Fatalf("metrics.OutcomeLocal now has %d observations; it is no longer "+ + "dead. Publishing published counters is a decision: update the "+ + "dashboards and STATUS.md, then delete this pin.", got) + } +} + +// TestPinnedEDNS0NSIDIsHex pins a contract of the pinned miekg/dns version +// (v1.1.73) that is easy to get wrong and fails silently: EDNS0_NSID.Nsid is +// a HEX STRING, not the opaque bytes RFC 5001 describes. pack() hex-decodes +// it and unpack() hex-encodes it, so: +// +// - a hex string packs, and the bytes on the wire are the decoded value; +// - a plain string such as "fancy-dns" does NOT pack — hex.DecodeString +// rejects the '-' — and the caller gets an error instead of a message. +// +// This is the trap behind the SERVFAIL bug: the engine used to put the literal +// "fancy-dns" in the option, so every query from a client that asked for NSID +// failed to build. If a future miekg/dns changes Nsid to raw bytes, this pin +// fails and the hex.EncodeToString in prepareUpstream has to go with it. +func TestPinnedEDNS0NSIDIsHex(t *testing.T) { + pack := func(nsid string) error { + m := new(dns.Msg) + m.SetQuestion("nsid.example.test.", dns.TypeA) + m.SetEdns0(1232, false) + m.IsEdns0().Option = append(m.IsEdns0().Option, &dns.EDNS0_NSID{Nsid: nsid}) + _, err := m.Pack() + return err + } + + if err := pack(hex.EncodeToString([]byte(upstreamNSID))); err != nil { + t.Fatalf("a hex-encoded NSID must pack: %v", err) + } + if err := pack(upstreamNSID); err == nil { + t.Fatalf("%q packed as an NSID; dns.EDNS0_NSID.Nsid is no longer hex-encoded, "+ + "so drop the hex.EncodeToString in prepareUpstream and update this pin", upstreamNSID) + } +} + +// TestClientNSIDIsAnswered pins the user-visible half of that fix: a client +// that asks for NSID gets a real answer, whatever the size of its NSID, and +// the option we forward upstream is our own identifier rather than the +// client's. With dns.hide_identity the option is dropped entirely, which is +// the whole point of the setting. +func TestClientNSIDIsAnswered(t *testing.T) { + t.Run("short NSID", func(t *testing.T) { + h := newHarness(t, hopt{}) + req := h.request("nsid.example.test.", dns.TypeA) + req.Msg.SetEdns0(1232, false) + req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option, + &dns.EDNS0_NSID{Nsid: "abc"}) + res := h.askWith(req) + requireRcode(t, res, dns.RcodeSuccess) + + got := h.up.received() + if len(got) != 1 { + t.Fatalf("upstream saw %d queries, want 1", len(got)) + } + opt, ok := findOption(got[0], dns.EDNS0NSID).(*dns.EDNS0_NSID) + if !ok { + t.Fatal("the forwarded query carries no NSID; we do not advertise ourselves upstream") + } + raw, err := hex.DecodeString(opt.Nsid) + if err != nil { + t.Fatalf("forwarded NSID %q is not hex: %v", opt.Nsid, err) + } + if string(raw) != upstreamNSID { + t.Fatalf("forwarded NSID = %q, want our own %q (we forwarded the client's)", + raw, upstreamNSID) + } + }) + + t.Run("NSID larger than 255 bytes", func(t *testing.T) { + // A character-string over 255 bytes does not pack for TXT, but NSID + // is hex-decoded into a byte slice, so a long NSID is fine here. + // Pinning it keeps the distinction between the two documented. + h := newHarness(t, hopt{}) + req := h.request("big-nsid.example.test.", dns.TypeA) + req.Msg.SetEdns0(1232, false) + req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option, + &dns.EDNS0_NSID{Nsid: hex.EncodeToString([]byte(strings.Repeat("a", 300)))}) + requireRcode(t, h.askWith(req), dns.RcodeSuccess) + }) + + t.Run("hide_identity drops the option", func(t *testing.T) { + h := newHarness(t, hopt{ + dnsCfg: func(d *config.DNS) { d.HideIdentity = true }, + }) + req := h.request("hidden-nsid.example.test.", dns.TypeA) + req.Msg.SetEdns0(1232, false) + req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option, + &dns.EDNS0_NSID{Nsid: "abc"}) + requireRcode(t, h.askWith(req), dns.RcodeSuccess) + if opt := findOption(h.up.received()[0], dns.EDNS0NSID); opt != nil { + t.Fatal("dns.hide_identity is set but the query still advertises an NSID") + } + }) +} diff --git a/internal/server/config_test.go b/internal/server/config_test.go new file mode 100644 index 0000000..708a182 --- /dev/null +++ b/internal/server/config_test.go @@ -0,0 +1,386 @@ +package server + +import ( + "context" + "encoding/base64" + "io" + "log/slog" + "net" + "net/http" + "strings" + "testing" + + "github.com/miekg/dns" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// freeTCPAddr returns a loopback address that was free a moment ago, so a +// test can name the port a listener must NOT have taken. +func freeTCPAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe listen: %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("probe close: %v", err) + } + return addr +} + +// TestDisabledListenerIsNotStartedAndBindsNothing is the "configured off" half +// of the per-listener contract. A disabled transport must not appear in the +// snapshot the admin API serves, and — the part Listeners() alone cannot show — +// it must not have taken its port. An operator who turned DoT off because the +// certificate was broken must be able to bind that port themselves. +func TestDisabledListenerIsNotStartedAndBindsNothing(t *testing.T) { + doTAddr := freeTCPAddr(t) + doHAddr := freeTCPAddr(t) + h := newHarness(t, func(c *config.Config) { + c.DNS.DoT = config.DoT{Enabled: false, Listen: []string{doTAddr}} + c.DNS.DoH = config.DoH{Enabled: false, Listen: []string{doHAddr}, BasePath: "/dns-query", AllowPost: true} + }) + + for _, tr := range h.transports() { + if tr == "dot" || tr == "doh" { + t.Errorf("disabled listener %q appears in %v", tr, h.srv.Listeners()) + } + } + if got := h.transports(); len(got) != 2 { + t.Errorf("transports = %v, want just udp and tcp", got) + } + for _, addr := range []string{doTAddr, doHAddr} { + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Errorf("port %s is not free after Start with the listener disabled: %v", addr, err) + continue + } + _ = ln.Close() + } +} + +// TestEnabledDoTWithoutCertIsAFailure covers the misconfiguration the disabled +// case hides: DoT switched on with no certificate cannot serve TLS, and the +// server says so instead of binding a port that answers nothing. +func TestEnabledDoTWithoutCertIsAFailure(t *testing.T) { + cfg := config.Default() + cfg.DNS.UDP = nil + cfg.DNS.TCP = nil + cfg.DNS.DoT = config.DoT{Enabled: true, Listen: []string{"127.0.0.1:0"}} + cfg.DNS.DoH = config.DoH{Enabled: false} + srv := New(Options{Engine: newFakeHandler(), Config: config.NewStore(cfg, ""), Logger: discardLogger()}) + + err := srv.Start(context.Background()) + if err == nil { + _ = srv.Stop(context.Background()) + t.Fatal("Start succeeded with DoT enabled and no certificate") + } + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + if got := srv.Listeners(); len(got) != 0 { + t.Errorf("Listeners() = %v, want empty", got) + } +} + +// TestStartReportsAPartialFailureButServes pins the two halves of the Start +// contract at once: a listener that cannot bind is an error worth returning, +// but it must not take the whole process down when another listener is fine. +// The admin API treats an empty listener list as degraded (app/hooks.go), so +// serving on the survivors is the difference between one broken transport and +// an outage. +func TestStartReportsAPartialFailureButServes(t *testing.T) { + // Port 1 is privileged: an unprivileged test process cannot bind it, which + // is a deterministic bind failure without a port race. + cfg := config.Default() + cfg.DNS.UDP = []string{"127.0.0.1:0"} + cfg.DNS.TCP = []string{"127.0.0.1:1"} + cfg.DNS.DoT = config.DoT{Enabled: false} + cfg.DNS.DoH = config.DoH{Enabled: false} + srv := New(Options{Engine: newFakeHandler(), Config: config.NewStore(cfg, ""), Logger: discardLogger()}) + if err := srv.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { _ = srv.Stop(context.Background()) }) + got := []string{} + for _, l := range srv.Listeners() { + got = append(got, l.Transport) + } + if len(got) != 1 || got[0] != "udp" { + t.Errorf("transports = %v, want just udp", got) + } + // The failing transport is not in the snapshot at all: ListenerInfo has an + // Err field, but a listener that never bound is never added, so the API + // cannot show an operator WHICH address failed. Pinned as current + // behaviour — see the report. + for _, l := range srv.Listeners() { + if l.Err != "" { + t.Errorf("listener %s carries Err %q; a failed bind is expected to be absent, not reported", l.Transport, l.Err) + } + } +} + +// TestStartTwiceIsRejected: a second Start must not re-bind and double every +// counter, and must say so rather than silently doing nothing useful. +func TestStartTwiceIsRejected(t *testing.T) { + h := newHarness(t, nil) + if err := h.srv.Start(context.Background()); err == nil { + t.Fatal("second Start succeeded, want an error") + } +} + +// TestDoHBasePathIsServedAtTheConfiguredPath: the DoH path is operator +// configuration, and a request to any other path must 404 rather than being +// answered — an Ingress path or a stale client that keeps using /dns-query +// needs to fail loudly. +func TestDoHBasePathIsServedAtTheConfiguredPath(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.DNS.DoH.BasePath = "/query" }) + cl := h.dohClient(false) + + // Configured path answers. + if res := h.askDoHGET(question("base.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" { + t.Errorf("A over the configured base path = %q", answerAddr(res)) + } + // RFC 8484 §4.1 alias still works when the base path is not the default. + res, _ := dohGetAt(cl, h, h.addr("doh"), "/.well-known/dns-query", question("alias.test.", dns.TypeA)) + if res != http.StatusOK { + t.Errorf("GET /.well-known/dns-query = %d, want 200", res) + } + // Anything else 404s and never reaches the engine. + for _, path := range []string{"/dns-query", "/query/extra", "/"} { + res, _ := dohGetAt(cl, h, h.addr("doh"), path, question("nope.test.", dns.TypeA)) + if res != http.StatusNotFound { + t.Errorf("GET %s = %d, want 404", path, res) + } + } + if n := len(h.fake.seen()); n != 2 { + t.Errorf("engine saw %d requests, want 2 (the configured path and the alias)", n) + } +} + +// TestDoHRejectsPostWhenNotAllowed: allow_post exists because some networks +// block POST. Turning it off must reject POST with the documented status and +// leave GET working, or the operator has turned DoH off entirely. +func TestDoHRejectsPostWhenNotAllowed(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.DNS.DoH.AllowPost = false }) + res, body := h.doHPOST(question("post.test.", dns.TypeA)) + if res.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("POST with allow_post=false = %d, want 405 (%s)", res.StatusCode, body) + } + if allow := res.Header.Get("Allow"); allow != "GET" { + t.Errorf("Allow = %q, want GET", allow) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("rejected POST reached the engine %d times", n) + } + if res := h.askDoHGET(question("post.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" { + t.Errorf("GET stopped working when POST was disabled: %q", answerAddr(res)) + } +} + +// TestDoHPostRequiresTheRFC8484ContentType: a POST whose body is a DNS message +// but whose media type is something else is not RFC 8484, and answering it +// would let a browser (which can post anything) reach the resolver. +func TestDoHPostRequiresTheRFC8484ContentType(t *testing.T) { + h := newHarness(t, nil) + res, body := h.doHPostWith(question("ct.test.", dns.TypeA), "application/octet-stream") + if res.StatusCode != http.StatusUnsupportedMediaType { + t.Errorf("POST with the wrong content type = %d, want 415 (%s)", res.StatusCode, body) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("wrong-content-type POST reached the engine %d times", n) + } +} + +// TestDoHRejectsNonQueryMethods: the handler is not a general HTTP endpoint. +func TestDoHRejectsNonQueryMethods(t *testing.T) { + h := newHarness(t, nil) + for _, method := range []string{http.MethodPut, http.MethodDelete, http.MethodHead} { + req, err := http.NewRequest(method, h.dohURL(h.base()), nil) + if err != nil { + t.Fatalf("%s request: %v", method, err) + } + res, err := h.dohClient(false).Do(req) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + _ = res.Body.Close() + if res.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("%s = %d, want 405", method, res.StatusCode) + } + if allow := res.Header.Get("Allow"); allow != "GET, POST" { + t.Errorf("%s Allow = %q, want \"GET, POST\"", method, allow) + } + } +} + +// TestDoHHidesVersionUnlessAsked pins cfgHideIdentity. The Server header is the +// only thing this switches off; the version string is an attacker's first +// question, and an operator who set hide_identity expects it gone from the +// encrypted listener too. +func TestDoHHidesVersionUnlessAsked(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.DNS.HideIdentity = true }) + res, _ := h.doHGET(question("hide.test.", dns.TypeA)) + if got := res.Header.Get("Server"); got != "" { + t.Errorf("Server header = %q, want it hidden", got) + } + if res := h.askDoHPOST(question("hide.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" { + t.Errorf("hide_identity broke the POST path: %q", answerAddr(res)) + } + + h2 := newHarness(t, nil) + res2, _ := h2.doHGET(question("show.test.", dns.TypeA)) + if got, want := res2.Header.Get("Server"), "fancy-dns/"+testVersion; got != want { + t.Errorf("Server header = %q, want %q", got, want) + } +} + +// TestDoHHideIdentityIsReadPerRequest: the flag lives in the hot-reloadable +// store, so turning it on must take effect without a restart. It is read on +// every request (cfgHideIdentity calls cfg.Get()), which is what makes the +// reload meaningful — and it is worth pinning, because freezing it at New +// would be an invisible regression. +func TestDoHHideIdentityIsReadPerRequest(t *testing.T) { + h := newHarness(t, nil) + res, _ := h.doHGET(question("reload.test.", dns.TypeA)) + if res.Header.Get("Server") == "" { + t.Fatal("version header missing before the reload") + } + next := *h.cfg + next.DNS.HideIdentity = true + if err := h.store.Swap(&next); err != nil { + t.Fatalf("swap: %v", err) + } + res, _ = h.doHGET(question("reload.test.", dns.TypeA)) + if got := res.Header.Get("Server"); got != "" { + t.Errorf("Server header = %q after hide_identity, want hidden", got) + } +} + +// TestDoHDropStillGetsABody: a dropped query must not hang the HTTP exchange. +// Unlike UDP, where silence is the answer, a DoH client waiting on a socket +// reads a missing body as a transport error and retries against a resolver +// that is working exactly as configured. +func TestDoHDropStillGetsABody(t *testing.T) { + h := newHarness(t, nil) + res, body := h.doHPOST(question("drop.test.", dns.TypeA)) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", res.StatusCode, body) + } + m := unpackDoH(t, body) + if len(m.Answer) != 0 { + t.Errorf("dropped query came back with %d answers: %v", len(m.Answer), m.Answer) + } + if got := h.fake.only(t); got.name != "drop.test." { + t.Errorf("name = %q, want drop.test.", got.name) + } +} + +// TestDoHMalformedRequestsAreRejected: every one of these is a 400/404 that +// must not reach the engine, because a request that cannot be understood is +// also a request the rate limiter and the query log should not see. +func TestDoHMalformedRequestsAreRejected(t *testing.T) { + h := newHarness(t, nil) + cl := h.dohClient(false) + addr := h.addr("doh") + base := h.base() + + post := func(body string, ct string) int { + res, err := cl.Post("https://"+addr+base, ct, stringReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + _ = res.Body.Close() + return res.StatusCode + } + + cases := []struct { + name string + run func() int + want int + }{ + {"no dns parameter", func() int { + res, _ := dohRawGet(cl, "https://"+addr+base+"?name=example.com") + return res + }, http.StatusBadRequest}, + {"unknown parameter", func() int { + res, _ := dohRawGet(cl, "https://"+addr+base+"?dns=AAAB&extra=1") + return res + }, http.StatusBadRequest}, + {"malformed base64", func() int { + res, _ := dohRawGet(cl, "https://"+addr+base+"?dns="+"not base64!!") + return res + }, http.StatusBadRequest}, + {"not a DNS message", func() int { + res, _ := dohRawGet(cl, "https://"+addr+base+"?dns=YWJjZGVmZ2g") + return res + }, http.StatusBadRequest}, + {"empty body", func() int { return post("", contentTypeDNS) }, http.StatusBadRequest}, + {"garbage body", func() int { return post("this is not a DNS message", contentTypeDNS) }, http.StatusBadRequest}, + {"header with no question", func() int { + // A well-formed DNS header with qdcount 0: parseable, unanswerable. + return post(string([]byte{0x12, 0x34, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}), contentTypeDNS) + }, http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.run(); got != tc.want { + t.Errorf("status = %d, want %d", got, tc.want) + } + }) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("a malformed request reached the engine: %+v", h.fake.seen()[:n]) + } +} + +// TestDoHBasePathCannotBeUsedForOtherPaths: a base path of "/" is what an +// operator writes to "serve DoH at the root". It answers every path, which is +// why healthz is registered explicitly and why this is worth knowing. +func TestDoHBasePathCannotBeUsedForOtherPaths(t *testing.T) { + h := newHarness(t, func(c *config.Config) { c.DNS.DoH.BasePath = "/" }) + if h.base() != "/" { + t.Fatalf("base = %q, want /", h.base()) + } + res, body := h.doHGET(question("root.test.", dns.TypeA)) + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", res.StatusCode, body) + } + // /healthz is registered ahead of the catch-all, so it still works. + hres, err := h.dohClient(false).Get(h.dohURL("/healthz")) + if err != nil { + t.Fatalf("healthz: %v", err) + } + _ = hres.Body.Close() + if hres.StatusCode != http.StatusOK { + t.Errorf("healthz = %d, want 200", hres.StatusCode) + } +} + +// dohGetAt performs a GET against an arbitrary path on the DoH listener, so a +// test can check a path the server is not supposed to serve. +func dohGetAt(cl *http.Client, _ *harness, addr, path string, m *dns.Msg) (int, []byte) { + wire, err := m.Pack() + if err != nil { + return 0, nil + } + res, body := dohRawGet(cl, "https://"+addr+path+"?dns="+base64.RawURLEncoding.EncodeToString(wire)) + return res, body +} + +// dohRawGet issues a GET and returns the status and body. +func dohRawGet(cl *http.Client, url string) (int, []byte) { + res, err := cl.Get(url) + if err != nil { + return 0, nil + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + return res.StatusCode, body +} + +// stringReader keeps the POST bodies in the malformed-request table readable. +func stringReader(s string) io.Reader { return strings.NewReader(s) } + +// discardLogger keeps the package's log output out of the test run. +func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } diff --git a/internal/server/doh_limits_test.go b/internal/server/doh_limits_test.go new file mode 100644 index 0000000..4aec2a8 --- /dev/null +++ b/internal/server/doh_limits_test.go @@ -0,0 +1,150 @@ +package server + +import ( + "bytes" + "encoding/base64" + "io" + "net/http" + "strings" + "testing" + + "github.com/miekg/dns" +) + +// The DoH handler is the one unauthenticated, TCP-terminated entry point in +// the process, so its size limits are a security control and not a nicety: +// AGENTS.md lists "amplification" under the areas that have been wrong before +// and must not lose their cap. Two paths, one cap each: +// +// - POST: readCapped refuses a body over dns.MaxMsgSize, both from an honest +// Content-Length and from a streamed body whose length is not declared. +// - GET: the ?dns= parameter must be refused when it cannot possibly be a +// DNS message — the encoding of a legal message is at most +// base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) characters, and a DNS +// message cannot exceed 65535 bytes on the wire (RFC 1035 §4.2.1, the +// 16-bit length prefix). Without that bound the only limit is net/http's +// MaxHeaderBytes, a megabyte of attacker-chosen base64 decoded per +// request, against a cap of 64 KiB on the POST path beside it. + +// oversizedBody returns a body just over the cap. Its content is irrelevant: +// nothing past the cap may be read, let alone unpacked. +func oversizedBody() []byte { return bytes.Repeat([]byte{0x00}, dns.MaxMsgSize+1) } + +// TestDoHPostBodyOverTheCapIsRefused covers the declared-length branch: an +// honest Content-Length over the cap is refused without reading the body. +func TestDoHPostBodyOverTheCapIsRefused(t *testing.T) { + h := newHarness(t, nil) + body := oversizedBody() + res, err := h.dohClient(false).Post(h.dohURL(h.base()), contentTypeDNS, bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + got, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want 400: %s", res.StatusCode, got) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("an over-cap body reached the engine %d times", n) + } +} + +// TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength is the branch that +// matters: a client may lie about, or omit, Content-Length. readCapped has to +// detect "too large" by reading max+1 bytes itself, not by trusting the +// header, or the cap is a suggestion. +func TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength(t *testing.T) { + h := newHarness(t, nil) + // An unknown-length body makes net/http send it chunked, so the server + // sees ContentLength == -1 and has to use its own reader. + body := io.NopCloser(bytes.NewReader(oversizedBody())) + req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), body) + if err != nil { + t.Fatalf("request: %v", err) + } + req.ContentLength = -1 + req.Header.Set("Content-Type", contentTypeDNS) + res, err := h.dohClient(false).Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + got, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want 400: %s", res.StatusCode, got) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("an over-cap chunked body reached the engine %d times", n) + } +} + +// TestDoHGETQueryStringOverTheCapIsRefused: a ?dns= parameter longer than any +// legal encoding is refused before it is decoded. +func TestDoHGETQueryStringOverTheCapIsRefused(t *testing.T) { + h := newHarness(t, nil) + cl := h.dohClient(false) + // One character past the longest possible encoding of a DNS message. + param := base64.RawURLEncoding.EncodeToString(make([]byte, dns.MaxMsgSize+1)) + for _, size := range []int{len(param), 512 * 1024} { + long := param + if size > len(param) { + long = param + strings.Repeat("A", size-len(param)) + } + res, err := cl.Get("https://" + h.addr("doh") + h.base() + "?dns=" + long) + if err != nil { + t.Fatalf("GET (%d chars): %v", len(long), err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + // 413 rather than 400: the parameter is a payload that is too big, + // and it keeps the two paths symmetric — readCapped on POST answers + // "cannot read body" for the same reason. A conforming client can + // never reach this status: the cap is the longest possible encoding + // of a legal DNS message. + if res.StatusCode != http.StatusRequestEntityTooLarge { + t.Errorf("GET with a %d-character dns parameter = %d, want 413: %s", len(long), res.StatusCode, body) + } + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("an over-cap query string reached the engine %d times", n) + } +} + +// TestDoHServesALargeButLegalQuery guards the cap from being set too tight. A +// resolver may legitimately be handed a message near the 65535-byte wire +// limit — here an EDNS0 padding option, the same trick the fake uses for a big +// answer — and a cap that rejected it would break real clients, not attackers. +func TestDoHServesALargeButLegalQuery(t *testing.T) { + h := newHarness(t, nil) + q := question("large.test.", dns.TypeA) + q.SetEdns0(4096, false) + // Size the padding so the packed message lands just under the 65535-byte + // wire limit: a header (12) + question (18) + OPT (11) + the padding + // option's own 4 bytes of code/length, plus the padding itself. + q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_NSID{Nsid: "00"}) + q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_PADDING{Padding: make([]byte, 65400)}) + wire, err := q.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if len(wire) <= 65000 || len(wire) > dns.MaxMsgSize { + t.Fatalf("fixture is %d bytes; it must be near the %d-byte limit and legal", len(wire), dns.MaxMsgSize) + } + if got := len(base64.RawURLEncoding.EncodeToString(wire)); got > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) { + t.Fatalf("fixture's encoding is %d characters, over the cap by construction", got) + } + + // GET. + res, body := h.doHGET(q) + if res.StatusCode != http.StatusOK { + t.Errorf("GET with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body) + } + // POST. + res, body = h.doHPOST(q) + if res.StatusCode != http.StatusOK { + t.Errorf("POST with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body) + } + if n := len(h.fake.seen()); n != 2 { + t.Errorf("engine saw %d requests, want 2", n) + } +} diff --git a/internal/server/funcs_test.go b/internal/server/funcs_test.go new file mode 100644 index 0000000..64d0143 --- /dev/null +++ b/internal/server/funcs_test.go @@ -0,0 +1,383 @@ +package server + +import ( + "bytes" + "crypto/tls" + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/miekg/dns" +) + +// countingReader counts the bytes readCapped actually pulled out of a body, so +// the "refused on the header" branch can be told apart from the "refused while +// reading" branch. Both return an error; only one of them let the client send +// the body. +type countingReader struct { + r io.Reader + n int +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += n + return n, err +} + +// errReader emits a prefix and then fails, standing in for a client that +// disconnects mid-body. readCapped has to surface that as an error: a truncated +// DNS message handed to dns.Msg.Unpack would be unpacked as garbage. +type errReader struct { + prefix string + err error + done bool +} + +func (f *errReader) Read(p []byte) (int, error) { + if f.done { + return 0, f.err + } + f.done = true + return copy(p, f.prefix), nil +} + +// TestReadCapped covers the DoH POST body reader. It is a security control, not +// a convenience: the handler is unauthenticated, and the bound is what stops a +// client from making the server allocate an arbitrary amount of memory per +// request. Two branches have to both work, because the whole cap is only as +// strong as the weaker one: +// +// - a declared Content-Length over the cap is refused WITHOUT reading the +// body at all (the header is a fast path, and the counting reader proves +// nothing was read); +// - a missing or understated Content-Length is caught by the reader itself, +// which takes max+1 bytes and only then says "too large". +func TestReadCapped(t *testing.T) { + const max = 16 + full := strings.Repeat("a", max) + cases := []struct { + name string + // body is what the client sends. + body string + // failAfter, when non-zero, makes the body stop with an error after + // that many bytes. + failAfter int + // length is the Content-Length the request advertises. -1 is + // net/http's value for a chunked request: no declared length at all. + length int64 + want string + // wantReads is the number of body bytes readCapped should have + // consumed. -1 means the case is not about how much was read. + wantReads int + wantErr bool + }{ + { + name: "an empty body is legal", body: "", length: 0, + want: "", wantReads: 0, + }, + { + name: "a body exactly at the cap is accepted whole", body: full, length: max, + want: full, wantReads: max, + }, + { + name: "one byte over the cap with an honest Content-Length is refused before the body is read", + body: full + "a", length: max + 1, + want: "", wantReads: 0, wantErr: true, + }, + { + // A client may claim any length it likes, so an over-declared + // length must not be the only thing that decides. It is still + // the fast path, so it must not cost a read. + name: "a wildly over-declared Content-Length is refused before the body is read", + body: full + "a", length: 64 << 20, + want: "", wantReads: 0, wantErr: true, + }, + { + // ContentLength == -1 is what net/http reports for a chunked + // request, so this is the branch a length-less client hits. + name: "one byte over the cap with no declared length is caught while reading", + body: full + "a", length: -1, + want: "", wantReads: max + 1, wantErr: true, + }, + { + name: "a body under the cap with no declared length is accepted whole", + body: "chunked-body", length: -1, + want: "chunked-body", wantReads: len("chunked-body"), + }, + { + // The cap is not the only thing that can end a read early, and a + // half-read body must not be handed on as if it were complete. + name: "a body that fails mid-read surfaces the read error", + body: "abcde", failAfter: 5, length: -1, + want: "", wantReads: 5, wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var src io.Reader = strings.NewReader(tc.body) + if tc.failAfter > 0 { + src = &errReader{prefix: tc.body, err: errors.New("connection reset by peer")} + } + cr := &countingReader{r: src} + r := httptest.NewRequest(http.MethodPost, "/dns-query", nil) + r.Body = io.NopCloser(cr) + r.ContentLength = tc.length + + got, err := readCapped(r, max) + if tc.wantErr { + if err == nil { + t.Fatalf("readCapped accepted %q, want an error", got) + } + if got != nil { + t.Errorf("readCapped returned %q alongside its error, want nil", got) + } + } else { + if err != nil { + t.Fatalf("readCapped(%q) = %v, want it accepted", tc.want, err) + } + if string(got) != tc.want { + t.Errorf("readCapped = %q, want %q", got, tc.want) + } + } + if tc.wantReads >= 0 && cr.n != tc.wantReads { + t.Errorf("readCapped read %d body bytes, want %d", cr.n, tc.wantReads) + } + }) + } +} + +// TestDecodeBase64URL covers the RFC 8484 §4.1 GET parameter decoder. The two +// encodings it accepts are base64url with and without padding: the RFC asks for +// the unpadded form, every real client sends the padded one, and refusing the +// padded form would break all of them. What it must NOT accept is the standard +// alphabet, because '+' and '/' have to be percent-encoded in a query string +// and a decoder that took them would be a decoder for a different encoding. +func TestDecodeBase64URL(t *testing.T) { + msg := []byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03} + // Standard base64 of these bytes contains both '+' and '/'. + std := base64.StdEncoding.EncodeToString(msg) + if !strings.ContainsAny(std, "+/") { + t.Fatalf("fixture %q contains no + or /; the standard-alphabet case would prove nothing", std) + } + cases := []struct { + name string + in string + want []byte + wantErr bool + }{ + {name: "unpadded base64url", in: base64.RawURLEncoding.EncodeToString(msg), want: msg}, + {name: "padded base64url", in: base64.URLEncoding.EncodeToString(msg), want: msg}, + {name: "standard base64 with + and / is rejected", in: std, wantErr: true}, + {name: "a space inside the parameter is rejected", in: "QQ BB", wantErr: true}, + {name: "a tab inside the parameter is rejected", in: "QQ\tBB", wantErr: true}, + { + // encoding/base64 skips CR and LF in every encoding, padded or + // not. A newline in ?dns= therefore decodes rather than 400s. + // It is harmless — the bytes are the same message — and it is + // pinned here so nobody "fixes" it by assuming the decoder + // rejects all whitespace. The size cap is applied to the raw + // parameter before decoding, so this cannot be used to smuggle + // an oversized message past the cap. + name: "a newline inside the parameter is ignored by the decoder", + in: "QQ\n==", want: []byte{0x41}, + }, + {name: "an empty parameter decodes to nothing", in: "", want: []byte{}}, + {name: "a truncated quantum is rejected", in: "QQQQQ", wantErr: true}, + {name: "a single character is rejected", in: "A", wantErr: true}, + { + // Two characters is a legal UNPADDED encoding of one byte, so it + // decodes on the RawURLEncoding path before the padded one is + // tried. It is not an error case; the case above ("QQQQQ") is, + // because five characters is not a whole number of quanta. + name: "two characters are a valid unpadded encoding of one byte", + in: "QQ", want: []byte{0x41}, + }, + { + // decodeBase64URL is a pure decoder: it imposes no size limit, + // because the limit that matters (the longest possible encoding + // of a 65535-byte DNS message) is a property of the caller's + // parameter, and the DoH handler applies it before calling here. + // The bound itself is pinned in TestDoHGETQueryStringOverTheCapIsRefused. + name: "a long but legal parameter decodes; the cap is the handler's job", + in: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x41}, 1<<20)), + want: bytes.Repeat([]byte{0x41}, 1<<20), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := decodeBase64URL(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("decodeBase64URL(%q) = %x, want an error", tc.in, got) + } + return + } + if err != nil { + t.Fatalf("decodeBase64URL(%q) = %v, want it accepted", tc.in, err) + } + if !bytes.Equal(got, tc.want) { + t.Errorf("decodeBase64URL(%q) = %x, want %x", tc.in, got, tc.want) + } + }) + } + // The cases above are the decoder's edge cases; this is the case that + // actually happens. A real query must survive the round trip byte for + // byte, in both encodings, or a client using the padded form (which is + // what curl and most browsers send) gets a 400 for a well-formed query. + wire, err := question("roundtrip.test.", dns.TypeA).Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + for _, enc := range []struct { + name string + in string + }{ + {"unpadded", base64.RawURLEncoding.EncodeToString(wire)}, + {"padded", base64.URLEncoding.EncodeToString(wire)}, + } { + t.Run("a real query round trips, "+enc.name, func(t *testing.T) { + got, err := decodeBase64URL(enc.in) + if err != nil { + t.Fatalf("decodeBase64URL: %v", err) + } + if !bytes.Equal(got, wire) { + t.Fatalf("round trip changed the message: %d bytes in, %d out", len(wire), len(got)) + } + if m := new(dns.Msg); m.Unpack(got) != nil { + t.Fatalf("the decoded bytes are not a DNS message") + } + }) + } +} + +// TestParseTLSVersion pins the fallback, which is a security setting: an +// operator who typos min_version, or leaves it empty, must get TLS 1.2 — never +// TLS 1.0 and never "whatever the Go default happens to be this release". +func TestParseTLSVersion(t *testing.T) { + cases := []struct { + in string + want uint16 + why string + }{ + {in: "1.2", want: tls.VersionTLS12, why: "the documented value"}, + {in: "1.3", want: tls.VersionTLS13, why: "the documented value"}, + {in: "1.0", want: tls.VersionTLS10, why: "still reachable, though config documents only 1.2/1.3"}, + {in: "1.1", want: tls.VersionTLS12, why: "unknown values fail UP to 1.2, never down to 1.1"}, + {in: "", want: tls.VersionTLS12, why: "an unset min_version means the secure default"}, + {in: "junk", want: tls.VersionTLS12, why: "a typo must not weaken the floor"}, + {in: "13", want: tls.VersionTLS12, why: "only the exact string 1.3 is honoured"}, + {in: "1.3 ", want: tls.VersionTLS12, why: "no trimming: an odd value falls back, it does not panic"}, + } + for _, tc := range cases { + t.Run("min_version="+tc.in, func(t *testing.T) { + if got := parseTLSVersion(tc.in); got != tc.want { + t.Errorf("parseTLSVersion(%q) = %d, want %d (%s)", tc.in, got, tc.want, tc.why) + } + }) + } +} + +// TestNormaliseBase covers the DoH base path normaliser. What it does is +// deliberately narrow — add the leading slash, trim the trailing ones — because +// it runs on operator config at Start and must never fail a startup. What it +// must NOT grow is a second parser: the path-traversal cases are here to pin +// that the string is passed through verbatim, because the thing that actually +// keeps a traversal out of the handler is net/http's ServeMux, which cleans +// the path before it matches. That is pinned by +// TestDoHBasePathTraversalNeverReachesTheHandler below; a sanitiser here would +// be a second, weaker implementation of the same rule. +func TestNormaliseBase(t *testing.T) { + cases := []struct{ in, want, why string }{ + {in: "", want: "/dns-query", why: "the default, so an unset base path still serves DoH"}, + {in: "query", want: "/query", why: "a path written without its leading slash is what operators type"}, + {in: "/query", want: "/query", why: "already normalised"}, + {in: "/query/", want: "/query", why: "a trailing slash would register a different mux pattern"}, + {in: "/query///", want: "/query", why: "all of them, and never the empty string"}, + {in: "/", want: "/", why: "serve at the root is a legal (if blunt) choice"}, + {in: "///", want: "/", why: "trimming stops at a single slash"}, + {in: "///query///", want: "///query", why: "only a MISSING leading slash is added; runs of them are the mux's business"}, + {in: "/a//b", want: "/a//b", why: "only the ends are touched; an inner // is left for the mux"}, + {in: "/query?x=1", want: "/query?x=1", why: "no query splitting: this is a path, not a URL"}, + {in: "/../etc", want: "/../etc", why: "passed through; ServeMux cleans the path (see the traversal test)"}, + {in: "/dns-query/..", want: "/dns-query/..", why: "passed through, same reason"}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := normaliseBase(tc.in); got != tc.want { + t.Errorf("normaliseBase(%q) = %q, want %q (%s)", tc.in, got, tc.want, tc.why) + } + }) + } +} + +// TestDoHBasePathTraversalNeverReachesTheHandler is the security half of the +// normaliseBase comment above. The DoH mux is built from a configured path, so +// the question is what happens when a request arrives with dot segments in it: +// it must never be answered by the DNS handler, and it must never reach the +// engine. net/http's ServeMux cleans the path and issues a 307 to the cleaned +// form, which is why there is no sanitiser in normaliseBase. Redirects are not +// followed here: following one would answer this test with the redirect +// TARGET's behaviour rather than the traversal's. +func TestDoHBasePathTraversalNeverReachesTheHandler(t *testing.T) { + h := newHarness(t, nil) + cl := noRedirectClient(t, h) + cases := []struct { + path string + want int + }{ + // Cleaned to /healthz, which is a registered path: a 307 to it, and + // the DNS handler never sees the request. ServeMux is what keeps the + // traversal out; normaliseBase deliberately does not try. + {path: "/dns-query/../healthz", want: http.StatusTemporaryRedirect}, + {path: "/dns-query/./x", want: http.StatusTemporaryRedirect}, + // Encoded dot segments are not cleaned by the mux, so they match no + // pattern at all. + {path: "/dns-query/%2e%2e/healthz", want: http.StatusNotFound}, + {path: "/dns-query/..%2fhealthz", want: http.StatusNotFound}, + {path: "/..%2f..%2fetc/passwd", want: http.StatusNotFound}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + res, err := cl.Get(h.dohURL(tc.path)) + if err != nil { + t.Fatalf("GET %s: %v", tc.path, err) + } + _ = res.Body.Close() + if res.StatusCode != tc.want { + t.Errorf("GET %s = %d, want %d", tc.path, res.StatusCode, tc.want) + } + }) + } + // Nothing above reached the engine, and the plain path still works, so + // the 404s are not the DNS handler being broken. Exactly one request + // reaches the engine: the control query. + h.fake.reset() + if a := answerAddr(h.askDoHGET(question("control.test.", dns.TypeA))); a != "203.0.113.9" { + t.Errorf("control query answered %q, want 203.0.113.9", a) + } + if n := len(h.fake.seen()); n != 1 { + t.Errorf("%d requests reached the engine, want exactly the control query: %+v", n, h.fake.seen()) + } +} + +// noRedirectClient is h.dohClient without redirect following, so a test can +// assert on the status the server produced rather than on whatever the +// Location header points at. +func noRedirectClient(t *testing.T, h *harness) *http.Client { + t.Helper() + tr := &http.Transport{TLSClientConfig: h.clientTLS()} + t.Cleanup(tr.CloseIdleConnections) + return &http.Client{ + Transport: tr, + Timeout: 5 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} diff --git a/internal/server/harness_test.go b/internal/server/harness_test.go new file mode 100644 index 0000000..dbc889b --- /dev/null +++ b/internal/server/harness_test.go @@ -0,0 +1,581 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "io" + "log/slog" + "math/big" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/engine" +) + +// testVersion is what the fake server reports in the DoH Server header. +const testVersion = "9.9.9-test" + +// recorded is one request as the handler saw it. The question is copied out +// of the *dns.Msg because the engine is allowed to mutate the message, and a +// test that asserts on a pointer the pipeline may rewrite is a flake waiting +// to happen. +type recorded struct { + transport string + name string + qtype uint16 + remote netip.Addr + tcp bool + names []string + tags []string + dohPath string + ednsSize uint16 +} + +// fakeHandler is the one Handler every transport funnels into. It records what +// it was asked and answers from a fixed recipe, so a test can tell "the UDP +// listener delivered this" from "the engine produced this". +type fakeHandler struct { + mu sync.Mutex + reqs []recorded + + inflight atomic.Int64 + flushes atomic.Uint64 + // packErr is the last failure to pack a response the handler built. A + // response that will not pack is silently dropped on the write path and + // presents as a client timeout, so it is recorded here rather than being + // discovered three layers away. + packErr error + + // arrived is signalled on every Handle entry, so a test can wait for a + // query to be *inside* the pipeline instead of sleeping and hoping. + arrived chan struct{} + + gate chan struct{} // non-nil while queries are parked + release chan struct{} // closed at cleanup so a held gate cannot wedge shutdown + + muGate sync.Mutex +} + +func newFakeHandler() *fakeHandler { + return &fakeHandler{ + arrived: make(chan struct{}, 64), + release: make(chan struct{}), + } +} + +// Handle records the request and answers from the recipe below. +func (f *fakeHandler) Handle(ctx context.Context, req engine.Request) engine.Response { + f.inflight.Add(1) + defer f.inflight.Add(-1) + + rec := recorded{ + transport: req.Transport, + remote: req.Remote, + tcp: req.TCP, + names: append([]string(nil), req.ClientNames...), + tags: append([]string(nil), req.ClientTags...), + dohPath: req.DoHPath, + } + if req.Msg != nil && len(req.Msg.Question) > 0 { + rec.name = req.Msg.Question[0].Name + rec.qtype = req.Msg.Question[0].Qtype + if o := req.Msg.IsEdns0(); o != nil { + rec.ednsSize = o.UDPSize() + } + } + f.mu.Lock() + f.reqs = append(f.reqs, rec) + f.mu.Unlock() + + select { + case f.arrived <- struct{}{}: + default: + } + + if g := f.gateCh(); g != nil { + select { + case <-g: + case <-f.release: + case <-ctx.Done(): + } + } + + res := f.answer(req.Msg, rec) + if res.Msg != nil { + if _, err := res.Msg.Pack(); err != nil { + f.mu.Lock() + f.packErr = err + f.mu.Unlock() + } + } + return res +} + +// answer is the canned response. The question name selects the outcome, so a +// test drives a blocked/cached/dropped/big answer by asking for it by name +// instead of reaching into the server. The reply is built from a copy of the +// query: SetReply carries the query's ID across, and a reply that invents its +// own ID is discarded by every real client. +func (f *fakeHandler) answer(q *dns.Msg, rec recorded) engine.Response { + m := newReply(q) + switch { + case strings.HasPrefix(rec.name, "block."): + return engine.Response{Msg: blockedReply(m), Action: "block", BlockSource: "test"} + case strings.HasPrefix(rec.name, "cached."): + return engine.Response{Msg: answerA(m), Action: "cached", CacheHit: true, CacheKind: "hit"} + case strings.HasPrefix(rec.name, "fail."): + m.Rcode = dns.RcodeServerFailure + return engine.Response{Msg: m, Action: "error"} + case strings.HasPrefix(rec.name, "drop."): + return engine.Response{Drop: true, Action: "drop"} + case strings.HasPrefix(rec.name, "big."): + return engine.Response{Msg: bigAnswer(m), Action: "allow"} + case len(rec.name) == 0: + m.Rcode = dns.RcodeFormatError + return engine.Response{Msg: m, Action: "error"} + } + return engine.Response{Msg: answerA(m), Action: "allow"} +} + +func (f *fakeHandler) Inflight() int64 { return f.inflight.Load() } +func (f *fakeHandler) Flush() { f.flushes.Add(1) } + +// flushes returns how many times the admin flush action reached the engine. +func (f *fakeHandler) flushCount() uint64 { return f.flushes.Load() } + +// hold parks every handler that arrives from now on until the returned func is +// called. It is how a test gets a query that is reliably in flight. +func (f *fakeHandler) hold() func() { + f.muGate.Lock() + defer f.muGate.Unlock() + if f.gate != nil { + return func() {} + } + g := make(chan struct{}) + f.gate = g + return func() { close(g) } +} + +func (f *fakeHandler) gateCh() chan struct{} { + f.muGate.Lock() + defer f.muGate.Unlock() + return f.gate +} + +// waitArrived blocks until a request has reached the handler. +func (f *fakeHandler) waitArrived(t *testing.T) { + t.Helper() + select { + case <-f.arrived: + case <-time.After(5 * time.Second): + t.Fatal("no query reached the handler") + } +} + +// lastPackErr reports the last response that failed to pack. +func (f *fakeHandler) lastPackErr() error { + f.mu.Lock() + defer f.mu.Unlock() + return f.packErr +} + +// seen returns a copy of everything recorded so far. +func (f *fakeHandler) seen() []recorded { + f.mu.Lock() + defer f.mu.Unlock() + return append([]recorded(nil), f.reqs...) +} + +// only asserts that exactly one request was recorded and returns it. +func (f *fakeHandler) only(t *testing.T) recorded { + t.Helper() + got := f.seen() + if len(got) != 1 { + t.Fatalf("handler saw %d requests, want exactly 1: %+v", len(got), got) + } + return got[0] +} + +// last returns the most recent recorded request. +func (f *fakeHandler) last() recorded { + got := f.seen() + if len(got) == 0 { + return recorded{} + } + return got[len(got)-1] +} + +// reset drops the recording, so one server can serve several independent +// questions. +func (f *fakeHandler) reset() { + f.mu.Lock() + f.reqs = nil + f.mu.Unlock() +} + +// newReply builds the shell of a reply: the query's ID, opcode, question and +// OPT. A real engine's finalize step does the EDNS0 handshake and SetReply +// carries neither the OPT nor a made-up ID, so a fake that skipped this would +// be the only thing in the path dropping them. +func newReply(q *dns.Msg) *dns.Msg { + m := new(dns.Msg) + m.SetReply(q.Copy()) + m.Authoritative = false + if o := q.IsEdns0(); o != nil { + m.SetEdns0(o.UDPSize(), o.Do()) + } + return m +} + +// answerA adds the canned A record to a prepared reply. +func answerA(m *dns.Msg) *dns.Msg { + if len(m.Question) > 0 && m.Question[0].Qtype == dns.TypeA { + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("203.0.113.9"), + }) + } + return m +} + +// blockedReply adds the 0.0.0.0 sinkhole answer. +func blockedReply(m *dns.Msg) *dns.Msg { + if len(m.Question) > 0 && m.Question[0].Qtype == dns.TypeA { + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("0.0.0.0"), + }) + } + return m +} + +// bigAnswer builds a multi-kilobyte answer out of many short TXT strings. +// One long string does NOT work: miekg/dns v1.1.73 reports a character-string +// over 255 bytes as zero length, so Msg.Pack fails with "dns: string exceeded +// 255 bytes" and, on a server write path, fails silently. 15 x 200 lands just +// over 3 KiB: far outside a 512-byte client buffer, inside the 4096 EDNS0 +// buffer the server accepts. +func bigAnswer(m *dns.Msg) *dns.Msg { + txt := make([]string, 15) + for i := range txt { + txt[i] = strings.Repeat("x", 200) + } + name := "." + if len(m.Question) > 0 { + name = m.Question[0].Name + } + m.Answer = append(m.Answer, &dns.TXT{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, + Txt: txt, + }) + return m +} + +// harness is a started Server with all four listeners on loopback:0, a fake +// engine behind them and a self-signed CA the test clients pin. +type harness struct { + t *testing.T + cfg *config.Config + store *config.Store + fake *fakeHandler + srv *Server + pool *x509.CertPool + cert tls.Certificate +} + +func newHarness(t *testing.T, mutate func(*config.Config)) *harness { + t.Helper() + certFile, keyFile := writeTestCert(t) + pool := testCertPool(t, certFile) + + cfg := config.Default() + cfg.DNS.UDP = []string{"127.0.0.1:0"} + cfg.DNS.TCP = []string{"127.0.0.1:0"} + cfg.DNS.DoT = config.DoT{Enabled: true, Listen: []string{"127.0.0.1:0"}, CertFile: certFile, KeyFile: keyFile, MinVersion: "1.2"} + cfg.DNS.DoH = config.DoH{Enabled: true, Listen: []string{"127.0.0.1:0"}, CertFile: certFile, KeyFile: keyFile, BasePath: "/dns-query", MinVersion: "1.2", AllowPost: true} + cfg.DNS.DoH.Transport = config.Transport{Mode: "auto"} + // A short stream idle timeout keeps a test that leaves a connection open + // from holding a goroutine for the four-second default. + cfg.Server.Timeouts.TLS = config.Duration(2 * time.Second) + if mutate != nil { + mutate(cfg) + } + + store := config.NewStore(cfg, "") + fake := newFakeHandler() + srv := New(Options{ + Engine: fake, + Config: store, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Version: testVersion, + }) + if err := srv.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(func() { + close(fake.release) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Stop(ctx) + }) + return &harness{t: t, cfg: cfg, store: store, fake: fake, srv: srv, pool: pool} +} + +// addr returns the bound address of one transport, read back from the +// listener snapshot rather than guessed. +func (h *harness) addr(transport string) string { + h.t.Helper() + for _, l := range h.srv.Listeners() { + if l.Transport == transport { + return l.Addr + } + } + h.t.Fatalf("no %s listener; have %+v", transport, h.srv.Listeners()) + return "" +} + +func (h *harness) transports() []string { + var out []string + for _, l := range h.srv.Listeners() { + out = append(out, l.Transport) + } + return out +} + +// base returns the normalised DoH base path. +func (h *harness) base() string { return normaliseBase(h.cfg.DNS.DoH.BasePath) } + +func (h *harness) clientTLS() *tls.Config { + return &tls.Config{RootCAs: h.pool, MinVersion: tls.VersionTLS12} +} + +// writeTestCert generates a self-signed certificate for 127.0.0.1 into +// t.TempDir and returns the two paths. Generating it in-test keeps the +// encrypted-listener tests self-contained: no testdata to expire. +func writeTestCert(t *testing.T) (certFile, keyFile string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "fancy-dns test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("certificate: %v", err) + } + kb, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + dir := t.TempDir() + certFile = filepath.Join(dir, "cert.pem") + keyFile = filepath.Join(dir, "key.pem") + if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certFile, keyFile +} + +// testCertPool returns a pool trusting only the generated certificate, so a +// client that skips verification is a deliberate choice rather than a habit. +func testCertPool(t *testing.T, certFile string) *x509.CertPool { + t.Helper() + pemBytes, err := os.ReadFile(certFile) + if err != nil { + t.Fatalf("read cert: %v", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + t.Fatal("test certificate did not parse into a pool") + } + return pool +} + +// ask builds a query and exchanges it over the named transport. The client +// timeouts are short on purpose: a hung listener must fail the test in +// seconds, not at the package timeout. +func (h *harness) ask(transport string, name string, qtype uint16) *dns.Msg { + h.t.Helper() + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(name), qtype) + m.RecursionDesired = true + switch transport { + case "udp": + return h.askUDP(m) + case "tcp": + return h.askTCP(m) + case "dot": + return h.askDoT(m) + case "doh": + return h.askDoHGET(m) + case "doh-post": + return h.askDoHPOST(m) + } + h.t.Fatalf("unknown transport %q", transport) + return nil +} + +func (h *harness) askUDP(m *dns.Msg) *dns.Msg { + h.t.Helper() + c := &dns.Client{Net: "udp", Timeout: 3 * time.Second} + res, _, err := c.Exchange(m, h.addr("udp")) + if err != nil { + h.t.Fatalf("udp exchange: %v", err) + } + return res +} + +func (h *harness) askTCP(m *dns.Msg) *dns.Msg { + h.t.Helper() + c := &dns.Client{Net: "tcp", Timeout: 3 * time.Second} + res, _, err := c.Exchange(m, h.addr("tcp")) + if err != nil { + h.t.Fatalf("tcp exchange: %v", err) + } + return res +} + +func (h *harness) askDoT(m *dns.Msg) *dns.Msg { + h.t.Helper() + tc := h.clientTLS() + tc.NextProtos = []string{"dot"} + c := &dns.Client{Net: "tcp-tls", Timeout: 3 * time.Second, TLSConfig: tc} + res, _, err := c.Exchange(m, h.addr("dot")) + if err != nil { + h.t.Fatalf("dot exchange: %v", err) + } + return res +} + +func (h *harness) dohURL(path string) string { + return "https://" + h.addr("doh") + path +} + +func (h *harness) dohClient(forceH2 bool) *http.Client { + tr := &http.Transport{TLSClientConfig: h.clientTLS(), ForceAttemptHTTP2: forceH2} + h.t.Cleanup(tr.CloseIdleConnections) + return &http.Client{Transport: tr, Timeout: 5 * time.Second} +} + +// askDoHGET issues the RFC 8484 GET profile with the wire message in ?dns=. +func (h *harness) askDoHGET(m *dns.Msg) *dns.Msg { + h.t.Helper() + res, body := h.doHGET(m) + if res.StatusCode != http.StatusOK { + h.t.Fatalf("doh GET status %d: %s", res.StatusCode, body) + } + return unpackDoH(h.t, body) +} + +// askDoHPOST issues the RFC 8484 POST profile. +func (h *harness) askDoHPOST(m *dns.Msg) *dns.Msg { + h.t.Helper() + res, body := h.doHPOST(m) + if res.StatusCode != http.StatusOK { + h.t.Fatalf("doh POST status %d: %s", res.StatusCode, body) + } + return unpackDoH(h.t, body) +} + +func (h *harness) doHGET(m *dns.Msg) (*http.Response, []byte) { + h.t.Helper() + wire, err := m.Pack() + if err != nil { + h.t.Fatalf("pack query: %v", err) + } + u := h.dohURL(h.base() + "?dns=" + base64.RawURLEncoding.EncodeToString(wire)) + res, err := h.dohClient(false).Get(u) + if err != nil { + h.t.Fatalf("doh GET: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + return res, body +} + +func (h *harness) doHPOST(m *dns.Msg) (*http.Response, []byte) { + h.t.Helper() + return h.doHPostWith(m, contentTypeDNS) +} + +func (h *harness) doHPostWith(m *dns.Msg, contentType string) (*http.Response, []byte) { + h.t.Helper() + wire, err := m.Pack() + if err != nil { + h.t.Fatalf("pack query: %v", err) + } + res, err := h.dohClient(false).Post(h.dohURL(h.base()), contentType, strings.NewReader(string(wire))) + if err != nil { + h.t.Fatalf("doh POST: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + return res, body +} + +func unpackDoH(t *testing.T, body []byte) *dns.Msg { + t.Helper() + m := new(dns.Msg) + if err := m.Unpack(body); err != nil { + t.Fatalf("unpack doh body (%d bytes): %v", len(body), err) + } + return m +} + +// waitFor polls cond until it holds or the deadline passes. Every wait in this +// suite is bounded: a hang looks exactly like a suite failure. +func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if cond() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %s waiting for %s", timeout, what) + } + time.Sleep(2 * time.Millisecond) + } +} + +// question builds a query the fake's recipe understands. +func question(name string, qtype uint16) *dns.Msg { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(name), qtype) + m.RecursionDesired = true + return m +} + +var _ = engine.Request{} diff --git a/internal/server/identity_test.go b/internal/server/identity_test.go new file mode 100644 index 0000000..6879d31 --- /dev/null +++ b/internal/server/identity_test.go @@ -0,0 +1,232 @@ +package server + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/miekg/dns" +) + +// TestSetIdentityReachesTheEngineRequest: the server resolves a client IP to +// reverse names and ACL tags before handing the query on, so the engine never +// has to do a PTR lookup on the hot path. Both the names and the tags must +// arrive, and the key they are resolved from must be the client's own address +// — a resolver that keys on anything else silently puts every client in one +// ACL group. +func TestSetIdentityReachesTheEngineRequest(t *testing.T) { + h := newHarness(t, nil) + var mu sync.Mutex + var keys []string + h.srv.SetIdentity( + func(ip string) []string { + mu.Lock() + keys = append(keys, ip) + mu.Unlock() + return []string{"host." + strings.ReplaceAll(ip, ".", "-") + ".test."} + }, + func(ip string) []string { return []string{"tag:" + ip} }, + ) + + h.ask("udp", "identity.test.", dns.TypeA) + got := h.fake.only(t) + if len(got.names) != 1 || got.names[0] != "host.127-0-0-1.test." { + t.Errorf("ClientNames = %v, want [host.127-0-0-1.test.]", got.names) + } + if len(got.tags) != 1 || got.tags[0] != "tag:127.0.0.1" { + t.Errorf("ClientTags = %v, want [tag:127.0.0.1]", got.tags) + } + mu.Lock() + defer mu.Unlock() + if len(keys) != 1 || keys[0] != "127.0.0.1" { + t.Errorf("identity was resolved for %v, want [127.0.0.1]", keys) + } + + // And it is consulted per request, not cached at Start: a second query + // after a change has to see the new answer. + h.srv.SetIdentity( + func(string) []string { return []string{"second.test."} }, + func(string) []string { return []string{"tag:second"} }, + ) + h.ask("tcp", "identity2.test.", dns.TypeA) + got = h.fake.last() + if len(got.names) != 1 || got.names[0] != "second.test." { + t.Errorf("ClientNames after the second SetIdentity = %v, want [second.test.]", got.names) + } + if len(got.tags) != 1 || got.tags[0] != "tag:second" { + t.Errorf("ClientTags after the second SetIdentity = %v, want [tag:second]", got.tags) + } +} + +// TestRequestWithoutIdentityIsStillAnswered: SetIdentity is optional. With the +// resolvers unset the atomic holders are nil, and the query path must not +// dereference them — a server wired before the app installed its resolvers +// would otherwise panic on the first packet. +func TestRequestWithoutIdentityIsStillAnswered(t *testing.T) { + h := newHarness(t, nil) + res := h.ask("udp", "noidentity.test.", dns.TypeA) + if answerAddr(res) != "203.0.113.9" { + t.Errorf("A = %q, want 203.0.113.9", answerAddr(res)) + } + got := h.fake.only(t) + if got.names != nil || got.tags != nil { + t.Errorf("ClientNames/ClientTags = %v/%v, want nil", got.names, got.tags) + } +} + +// TestAddrToAddr covers the address normalisation every client identity, ACL +// and rate-limit decision depends on. An IPv4 client must arrive as an IPv4 +// address even when the kernel hands us a v4-mapped v6 one, or every CIDR in +// every ACL group stops matching. +func TestAddrToAddr(t *testing.T) { + cases := []struct { + name string + addr net.Addr + want string + }{ + {"udp IPv4", &net.UDPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"}, + {"tcp IPv4", &net.TCPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"}, + // A dual-stack listener reports IPv4 clients as ::ffff:a.b.c.d; without + // Unmap the ACL lookup would be handed "::ffff:192.0.2.7" and match no + // IPv4 group. + {"udp IPv4-mapped IPv6", &net.UDPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"}, + {"tcp IPv4-mapped IPv6", &net.TCPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"}, + {"udp IPv6", &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 53}, "2001:db8::1"}, + // A scoped link-local address keeps its zone: dropping it would change + // which interface the address names. + {"udp IPv6 with a zone", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Port: 53, Zone: "eth0"}, "fe80::1%eth0"}, + {"custom addr with host:port", stringAddr("198.51.100.4:34567"), "198.51.100.4"}, + {"custom addr with a bare IP", stringAddr("198.51.100.5"), "198.51.100.5"}, + // Unparseable: the zero Addr. A client identity lookup on it must not + // panic, and the caller sees the invalid address. + {"custom addr with garbage", stringAddr("not-an-address"), "invalid IP"}, + {"custom addr with a bare hostname", stringAddr("localhost"), "invalid IP"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := addrToAddr(tc.addr) + if got.String() != tc.want { + t.Errorf("addrToAddr(%s) = %q, want %q", tc.addr, got, tc.want) + } + if tc.want == "invalid IP" { + if got.IsValid() { + t.Errorf("addrToAddr returned a valid address %v for unparseable input", got) + } + return + } + if !got.IsValid() { + t.Errorf("addrToAddr returned the invalid address for %s", tc.addr) + } + }) + } +} + +// TestHTTPRemoteAddr covers the DoH adapter that turns an http.Request's +// RemoteAddr into the net.Addr the shared request builder wants. +func TestHTTPRemoteAddr(t *testing.T) { + cases := []struct { + name string + remoteAddr string + want string + }{ + {"IPv4 with a port", "192.0.2.9:54321", "192.0.2.9"}, + {"IPv6 with a port", "[2001:db8::1]:54321", "2001:db8::1"}, + {"IPv4-mapped IPv6", "[::ffff:192.0.2.9]:54321", "192.0.2.9"}, + // An empty or unparseable RemoteAddr cannot become a client identity; + // the zero TCPAddr degrades to the invalid address rather than + // inventing one. + {"empty", "", "invalid IP"}, + {"garbage", "not-an-address", "invalid IP"}, + {"no port", "192.0.2.9", "invalid IP"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "https://example.test/dns-query", nil) + r.RemoteAddr = tc.remoteAddr + got := addrToAddr(httpRemoteAddr(r)) + if got.String() != tc.want { + t.Errorf("httpRemoteAddr(%q) -> %q, want %q", tc.remoteAddr, got, tc.want) + } + }) + } +} + +// TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader pins a known +// limitation, deliberately. httpRemoteAddr (server.go) uses the raw +// RemoteAddr and never consults X-Forwarded-For, even though the admin API in +// the same binary trusts that header from trusted_proxies. So a DoH query that +// arrives through the Ingress sees the Ingress's address as its client. +// +// Consequence, and the reason this needs a test before anyone "fixes" it: the +// DoH ACL group is the proxy's group, not the client's. Behind the shipped +// ClusterIP Ingress every DoH client lands in the same group — fail-CLOSED if +// that group is restrictive (a legitimate DoH client is refused) and fail-OPEN +// if it is permissive (a client outside its intended group is allowed). This +// is a security decision, not a bug to be quietly patched: turning on +// X-Forwarded-For without consulting trusted_proxies would let any client +// spoof its identity past the ACL and the rate limiter, which is exactly what +// the AGENTS.md invariant forbids. The fix, if it is ever wanted, is a trusted +// proxy list for the DoH listener and a rewrite of this test. +func TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader(t *testing.T) { + h := newHarness(t, nil) + var mu sync.Mutex + var keys []string + h.srv.SetIdentity( + func(ip string) []string { + mu.Lock() + keys = append(keys, ip) + mu.Unlock() + return []string{"client.test."} + }, + func(ip string) []string { return nil }, + ) + + wire, err := question("xff.test.", dns.TypeA).Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), strings.NewReader(string(wire))) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Header.Set("Content-Type", contentTypeDNS) + // Both headers an operator would expect to be honoured. Neither is. + req.Header.Set("X-Forwarded-For", "203.0.113.66") + req.Header.Set("X-Real-IP", "203.0.113.66") + req.Header.Set("Forwarded", "for=203.0.113.66") + res, err := h.dohClient(false).Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", res.StatusCode) + } + + got := h.fake.only(t) + // The client connected from loopback, so the proxy IS loopback here. + if got.remote.String() != "127.0.0.1" { + t.Errorf("Request.Remote = %v, want the socket peer 127.0.0.1", got.remote) + } + mu.Lock() + defer mu.Unlock() + for _, k := range keys { + if k == "203.0.113.66" { + t.Fatalf("identity was resolved for the X-Forwarded-For address %q; httpRemoteAddr must ignore it", k) + } + } + if len(keys) == 0 || keys[0] != "127.0.0.1" { + t.Errorf("identity keys = %v, want [127.0.0.1]", keys) + } +} + +// stringAddr is a net.Addr that is neither TCP nor UDP, so addrToAddr has to +// fall back to parsing its string — which is the path a wrapped or proxied +// connection takes. +type stringAddr string + +func (s stringAddr) Network() string { return "test" } +func (s stringAddr) String() string { return string(s) } diff --git a/internal/server/server.go b/internal/server/server.go index 12827e0..185d49d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -210,7 +210,16 @@ func (s *Server) Stop(ctx context.Context) error { } var errs []error for _, l := range ls { - if err := l.close(); err != nil { + if err := l.close(); err != nil && !errors.Is(err, net.ErrClosed) { + // The DoH listener is closed TWICE, by two different owners: once + // from this list, and once by http.Server.Shutdown, which tracks + // the very same net.Listener because serveDoH hands it to + // srv.Serve. Whichever of the two lost that race made an + // otherwise clean Stop return "use of closed network + // connection" at random, so a clean shutdown looked like a + // failed one. Closing an already-closed listener is not a + // failure; only a real close error is. Pinned by + // TestStopIsIdempotent. errs = append(errs, err) } } @@ -583,9 +592,6 @@ func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error { const contentTypeDNS = "application/dns-message" -// maxGETResponse bounds a DoH GET response so no intermediary truncates it. -const maxGETResponse = 65535 - // doHHandler builds the RFC 8484 handler. func (s *Server) doHHandler(base string, allowPost bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -616,7 +622,18 @@ func (s *Server) doHHandler(base string, allowPost bool) http.Handler { } } } - payload, err = decodeBase64URL(q.Get("dns")) + // A DNS message is at most 65535 bytes on the wire (every section + // carries a 16-bit length), so its base64url form cannot be + // longer than this. POST gets the same bound from readCapped; + // without it here an unauthenticated client could make the + // server allocate, base64-decode and Unpack a megabyte per + // request, where the adjacent POST path refuses at 64 KiB. + enc := q.Get("dns") + if len(enc) > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) { + writeDoHError(w, http.StatusRequestEntityTooLarge, "dns parameter too large") + return + } + payload, err = decodeBase64URL(enc) if err != nil { writeDoHError(w, http.StatusBadRequest, "malformed dns parameter") return @@ -656,7 +673,11 @@ func (s *Server) doHHandler(base string, allowPost bool) http.Handler { if res.Drop || res.Msg == nil { // A dropped query still needs a body: unlike UDP, this is a // well-formed HTTP exchange, so hanging up would read as a - // transport error rather than a policy decision. + // transport error rather than a policy decision. It is still a + // drop, so it counts as one here too — the UDP and TCP paths bump + // "dropped" in the same position, and the DoH path used to be + // the only one that did not. + s.bump("doh", "dropped") res.Msg = new(dns.Msg) res.Msg.SetRcode(m, dns.RcodeSuccess) } diff --git a/internal/server/shutdown_test.go b/internal/server/shutdown_test.go new file mode 100644 index 0000000..7d72c8b --- /dev/null +++ b/internal/server/shutdown_test.go @@ -0,0 +1,379 @@ +package server + +import ( + "bytes" + "context" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/miekg/dns" +) + +// Shutdown is the only part of this package that can lose a client's query, +// so it is tested as behaviour rather than as a sequence: the socket is +// released, Stop is idempotent, and a query that is already inside the +// pipeline is still answered. The last one is the invariant that matters — a +// resolver that answers "connection closed" to a query it had already accepted +// makes every client retry against somewhere else. + +// holdOnce parks every handler that arrives from now on and returns a release +// func that is safe to call twice, so a failing test can unblock the gate in +// its cleanup without a double-close panic. +func holdOnce(t *testing.T, f *fakeHandler) func() { + t.Helper() + release := f.hold() + var once sync.Once + out := func() { once.Do(release) } + t.Cleanup(out) + return out +} + +// startStop calls srv.Stop in the background and returns the channel it will +// report on. stopWithin is that plus a bounded wait; the in-flight test needs +// the gap, because it has to watch the shutdown overlap a parked query instead +// of waiting for it to finish. +func startStop(srv *Server, ctxTimeout time.Duration) <-chan error { + done := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), ctxTimeout) + defer cancel() + done <- srv.Stop(ctx) + }() + return done +} + +// awaitStop waits for a Stop started by startStop. +func awaitStop(t *testing.T, done <-chan error, d time.Duration) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(d): + t.Fatalf("Stop did not return within %s", d) + return nil + } +} + +// stopWithin calls srv.Stop and fails the test if it has not returned within d. +// A hang inside a test binary is reported as a package timeout, which says +// nothing about which test caused it; bounding every Stop call here makes the +// failure name the test. +func stopWithin(t *testing.T, srv *Server, d time.Duration) error { + t.Helper() + return awaitStop(t, startStop(srv, 5*time.Second), d) +} + +// bindFree tries to bind network/addr and closes the socket again. It reports +// the error rather than failing so a caller can retry: a socket that was +// closed a moment ago can still be in TIME_WAIT, and the point of every use +// here is that a *second* bind of the port works, not that the first one does. +func bindFree(network, addr string) error { + if network == "udp" { + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return err + } + return pc.Close() + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return ln.Close() +} + +// waitPortFree blocks until network/addr can be bound. Use it only when nothing +// else holds the port: a connected client's own local socket is on that same +// port, so a bind probe can never succeed while a connection is open. The +// in-flight test uses waitPortRefused for exactly that reason. +func waitPortFree(t *testing.T, network, addr, what string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + err := bindFree(network, addr) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("%s: %s %s is still bound 2s after Stop was called (%v)", what, network, addr, err) + } + time.Sleep(2 * time.Millisecond) + } +} + +// waitPortRefused blocks until a dial to addr is refused, which is how the +// in-flight test observes that Stop has reached the point where it closes the +// listeners — before it waits for its goroutines. A refused dial is the right +// probe there: the parked query holds a *connection* to that port, so binding +// it would fail for a reason that has nothing to do with Stop. +func waitPortRefused(t *testing.T, network, addr, what string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + conn, err := net.DialTimeout(network, addr, 200*time.Millisecond) + if err != nil { + return + } + conn.Close() + if time.Now().After(deadline) { + t.Fatalf("%s: %s %s still accepts connections 2s after Stop was called", what, network, addr) + } + time.Sleep(2 * time.Millisecond) + } +} + +// TestStopReleasesEveryListenerPort: an operator who restarts fancy-dns — or +// hands the port to something else — must be able to bind it again. It also +// empties the listener snapshot, because that is what the admin API and the +// cluster node state read: a listener still reported as running after the +// process stopped serving on it sends the health check the wrong answer. +func TestStopReleasesEveryListenerPort(t *testing.T) { + h := newHarness(t, nil) + addrs := map[string]string{} + for _, l := range h.srv.Listeners() { + addrs[l.Transport] = l.Addr + } + if len(addrs) != 4 { + t.Fatalf("fixture bound %d listeners, want 4: %+v", len(addrs), addrs) + } + if err := stopWithin(t, h.srv, 2*time.Second); err != nil { + t.Fatalf("Stop: %v", err) + } + for transport, addr := range addrs { + network := "tcp" + if transport == "udp" { + network = "udp" + } + // One retry: TIME_WAIT is a property of the socket, not a listener + // still being held, and failing on it would make this a flake. + var err error + for i := 0; i < 2; i++ { + if err = bindFree(network, addr); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if err != nil { + t.Errorf("%s %s could not be bound after Stop: %v", network, addr, err) + } + } + if got := h.srv.Listeners(); len(got) != 0 { + t.Errorf("Listeners() = %+v after Stop, want empty", got) + } +} + +// TestStopIsIdempotent: Stop is called from signal handling, from an admin +// endpoint and from a test cleanup, so more than one of them can reach a +// server that is already down. A second call must be a no-op returning nil — +// not a panic on a half-drained listener list, and not a wait on goroutines +// that are already gone. The same is true of a server that never started at +// all, which is what a config error produces. +func TestStopIsIdempotent(t *testing.T) { + h := newHarness(t, nil) + if err := stopWithin(t, h.srv, 2*time.Second); err != nil { + t.Fatalf("first Stop: %v", err) + } + // The code clears started before it closes anything, so the second call + // short-circuits on that flag and never re-closes a listener or re-waits + // on the wait group. Pinned: a second Stop that returned the first Stop's + // error, or waited again, would be a bug in its own right. + if err := stopWithin(t, h.srv, 2*time.Second); err != nil { + t.Errorf("second Stop = %v, want nil", err) + } + if err := stopWithin(t, h.srv, 2*time.Second); err != nil { + t.Errorf("third Stop = %v, want nil", err) + } + // A server that never started: nothing to close, and nothing to say. + if err := stopWithin(t, New(Options{}), 2*time.Second); err != nil { + t.Errorf("Stop on a server that never started = %v, want nil", err) + } +} + +// TestStopWithAnAlreadyCancelledContextReturnsPromptly: the context bounds how +// long Stop waits, and a caller that has already given up must not be made to +// wait for a listener to close. Only the bound is asserted — never a +// duration. The return *value* is deliberately not asserted: Stop races its +// own wait group against ctx.Done() and takes whichever is ready first, so the +// same call can return nil or context.Canceled depending on how the shutdown +// went. Asserting either would pin the scheduler, not the code. +// +// What IS asserted is that the listeners are released even then: closing them +// is not the part the context is allowed to interrupt, because that is the +// work the next process start needs. +func TestStopWithAnAlreadyCancelledContextReturnsPromptly(t *testing.T) { + h := newHarness(t, nil) + udp := h.addr("udp") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan error, 1) + go func() { done <- h.srv.Stop(ctx) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Stop with an already-cancelled context did not return within 2s") + } + waitPortFree(t, "udp", udp, "a Stop whose context was already cancelled") +} + +// TestInFlightQueryIsAnsweredAcrossStop is the invariant in this file. A +// query that has been accepted and is inside the pipeline belongs to the +// client: closing the listener must not make it vanish. The client here has +// its query parked in the handler while Stop runs, so if the shutdown path +// abandoned in-flight work the client would see a closed connection (TCP) or a +// 5xx/EOF (DoH) and every client would retry elsewhere. +// +// The test proves Stop is really in flight before the handler is released: it +// waits for the listener's port to become bindable, which Stop does before it +// waits for its goroutines. Without that ordering the test would pass even if +// the shutdown never overlapped the query. +func TestInFlightQueryIsAnsweredAcrossStop(t *testing.T) { + t.Run("tcp", func(t *testing.T) { + h := newHarness(t, nil) + release := holdOnce(t, h.fake) + conn, err := net.DialTimeout("tcp", h.addr("tcp"), 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + co := &dns.Conn{Conn: conn} + if err := co.WriteMsg(question("inflight-shutdown.test.", dns.TypeA)); err != nil { + t.Fatalf("write: %v", err) + } + h.fake.waitArrived(t) + + // Stop runs in the background and the test watches the overlap: it + // closes the listener before it waits for its goroutines, so a refused + // dial is proof that the shutdown is running *while* the query is + // still parked in the handler. Without that ordering the test would + // pass even if the shutdown never overlapped the query at all. + stopped := startStop(h.srv, 5*time.Second) + waitPortRefused(t, "tcp", h.addr("tcp"), "Stop closing the TCP listener") + + release() + res, err := co.ReadMsg() + if err != nil { + t.Fatalf("the in-flight query was lost across Stop: %v", err) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("A = %q, want 203.0.113.9 (reply: %v)", a, res) + } + if err := awaitStop(t, stopped, 3*time.Second); err != nil { + t.Errorf("Stop: %v", err) + } + // Stop waits for the connection goroutines, so by the time it returns + // nothing is still inside the engine. A shutdown that returned while a + // query was in flight would leave the process exiting with work + // running. + if got := h.srv.Stats().Inflight; got != 0 { + t.Errorf("Stats().Inflight = %d after Stop returned, want 0", got) + } + }) + + t.Run("doh", func(t *testing.T) { + h := newHarness(t, nil) + release := holdOnce(t, h.fake) + cl := h.dohClient(false) + type reply struct { + status int + body []byte + err error + } + got := make(chan reply, 1) + // The request is issued from its own goroutine: it does not come back + // until the handler is released, which happens after Stop has started. + // h.doHPOST is not usable here because it calls t.Fatalf, and a + // Fatalf from a non-test goroutine does not stop the test. + go func() { + wire, err := question("inflight-shutdown.test.", dns.TypeA).Pack() + if err != nil { + got <- reply{err: err} + return + } + res, err := cl.Post(h.dohURL(h.base()), contentTypeDNS, bytes.NewReader(wire)) + if err != nil { + got <- reply{err: err} + return + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + got <- reply{status: res.StatusCode, body: body} + }() + h.fake.waitArrived(t) + + // The DoH path is the interesting one: http.Server.Shutdown waits for + // the in-flight request rather than cancelling it, so Stop has to + // block until the answer has been written. It must still return, and + // the client must still get its answer. The probe is the same as on + // the TCP side: the listener is closed before Stop waits. + stopped := startStop(h.srv, 5*time.Second) + waitPortRefused(t, "tcp", h.addr("doh"), "Stop closing the DoH listener") + + release() + select { + case r := <-got: + if r.err != nil { + t.Fatalf("the in-flight DoH request failed across Stop: %v", r.err) + } + if r.status != 200 { + t.Fatalf("status = %d, want 200: %s", r.status, r.body) + } + if a := answerAddr(unpackDoH(t, r.body)); a != "203.0.113.9" { + t.Errorf("A = %q, want 203.0.113.9", a) + } + case <-time.After(3 * time.Second): + t.Fatal("the in-flight DoH request never came back") + } + if err := awaitStop(t, stopped, 3*time.Second); err != nil { + t.Errorf("Stop: %v", err) + } + if got := h.srv.Stats().Inflight; got != 0 { + t.Errorf("Stats().Inflight = %d after Stop returned, want 0", got) + } + }) +} + +// TestStopWithNoQueryInFlightReturnsAndStartsAgain: an idle Stop has nothing +// to wait for, so it returns immediately — the bound here is generous because +// the point is "does not hang", not "is fast". Then the process has to be able +// to serve again: the same Server is started a second time, binds four fresh +// listeners and answers. The counters deliberately survive the restart: /status +// is a lifetime view, and a counter that reset on restart would make the +// console's time series jump to zero every time the pod rolled. +func TestStopWithNoQueryInFlightReturnsAndStartsAgain(t *testing.T) { + h := newHarness(t, nil) + before := h.addr("udp") + if err := stopWithin(t, h.srv, 2*time.Second); err != nil { + t.Fatalf("Stop: %v", err) + } + if err := h.srv.Start(context.Background()); err != nil { + t.Fatalf("Start after Stop: %v", err) + } + if got := len(h.srv.Listeners()); got != 4 { + t.Errorf("Listeners() after the restart has %d entries, want 4", got) + } + res := h.ask("udp", "after-restart.test.", dns.TypeA) + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("A after the restart = %q, want 203.0.113.9", a) + } + if got := h.fake.last().name; got != "after-restart.test." { + t.Errorf("the restarted server did not reach the engine: last question %q", got) + } + if got := h.srv.ByTransport()["udp"].Queries; got != 1 { + t.Errorf("udp.Queries = %d after the restart, want 1 (counters are a lifetime total)", got) + } + // The new listeners are different sockets: the harness binds port 0, so a + // restart is only possible because Stop gave the old ones back. Asserting + // it also keeps the test honest about which server answered. + if after := h.addr("udp"); after == before { + t.Logf("udp rebound the same address %s; port 0 makes this possible but unlikely", after) + } + // And the harness's own cleanup still stops the restarted server without + // complaint, which is the t.Cleanup path every test in this package takes. + if got := h.srv.Stats().Inflight; got != 0 { + t.Errorf("Stats().Inflight = %d, want 0", got) + } +} diff --git a/internal/server/stats.go b/internal/server/stats.go index 4d12894..4ac25ef 100644 --- a/internal/server/stats.go +++ b/internal/server/stats.go @@ -29,6 +29,7 @@ func (s *Server) ByTransport() map[string]TransportStats { ts.Blocked = s.load(t, "blocked") ts.Cached = s.load(t, "cached") ts.Errors = s.load(t, "errors") + ts.Dropped = s.load(t, "dropped") ts.Truncated = s.load(t, "truncated") ts.Sent = s.load(t, "sent") out[t] = ts @@ -51,6 +52,12 @@ type TransportStats struct { Blocked uint64 `json:"blocked"` Cached uint64 `json:"cached"` Errors uint64 `json:"errors"` + // Dropped is queries answered with silence on this transport. It is + // bumped for every transport (a DoH drop used to be counted as an error + // only, which made the console's transport tab under-report it), and + // aggregating it per transport is the only way the UI can show it: the + // aggregate in HandlerStats cannot say which transport dropped. + Dropped uint64 `json:"dropped"` Truncated uint64 `json:"truncated"` Sent uint64 `json:"sent"` } diff --git a/internal/server/stats_test.go b/internal/server/stats_test.go new file mode 100644 index 0000000..4d820e2 --- /dev/null +++ b/internal/server/stats_test.go @@ -0,0 +1,546 @@ +package server + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +// The counters are what the console's status page and the /status endpoint +// report, so they are the only account an operator has of what the server is +// doing. Everything in this file is about the numbers being right rather than +// merely present: an under-counted drop reads as a working resolver, and a +// double-counted query reads as a busy one. +// +// The harness's fake handler selects its outcome from the question name +// (block., cached., fail., drop., big.), so a test drives the exact counter +// move it wants by asking for it by name. No counter is bumped by hand. + +var statTransports = []string{"udp", "tcp", "dot", "doh"} + +// silenceWait is how long a transport is given to produce the answer the fake +// intends to produce. A drop is answered with nothing at all, so over UDP this +// is the price of asking about one. +const silenceWait = 300 * time.Millisecond + +// exchangeOnce sends m over transport and returns the reply, or nil when the +// transport answered with silence or hung up. h.ask cannot be used here: it +// treats a missing answer as a test failure, and "no answer" is a legitimate +// outcome the fake produces on purpose. +func exchangeOnce(t *testing.T, h *harness, transport string, m *dns.Msg) *dns.Msg { + t.Helper() + switch transport { + case "udp": + return exchangeUDP(t, h, m) + case "tcp": + return exchangeStream(t, h.addr("tcp"), nil, m) + case "dot": + tc := h.clientTLS() + tc.NextProtos = []string{"dot"} + return exchangeStream(t, h.addr("dot"), tc, m) + case "doh": + res, body := h.doHPOST(m) + if res.StatusCode != http.StatusOK { + t.Fatalf("doh POST = %d, want 200: %s", res.StatusCode, body) + } + return unpackDoH(t, body) + } + t.Fatalf("unknown transport %q", transport) + return nil +} + +func exchangeUDP(t *testing.T, h *harness, m *dns.Msg) *dns.Msg { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + wire, err := m.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if _, err := pc.WriteTo(wire, addr); err != nil { + t.Fatalf("write: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(silenceWait)) + buf := make([]byte, 4096) + n, _, err := pc.ReadFrom(buf) + if err != nil { + return nil // silence, or a datagram that never came + } + res := new(dns.Msg) + if err := res.Unpack(buf[:n]); err != nil { + t.Fatalf("unpack reply: %v", err) + } + return res +} + +func exchangeStream(t *testing.T, addr string, tc *tls.Config, m *dns.Msg) *dns.Msg { + t.Helper() + d := &net.Dialer{Timeout: 2 * time.Second} + var ( + conn net.Conn + err error + ) + if tc != nil { + conn, err = tls.DialWithDialer(d, "tcp", addr, tc) + } else { + conn, err = d.Dial("tcp", addr) + } + if err != nil { + t.Fatalf("dial %s: %v", addr, err) + } + t.Cleanup(func() { _ = conn.Close() }) + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + co := &dns.Conn{Conn: conn} + if err := co.WriteMsg(m); err != nil { + t.Fatalf("write: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + res, err := co.ReadMsg() + if err != nil { + return nil // a drop closes the connection instead of answering + } + return res +} + +// counterDelta is the change one query is expected to make to a transport's +// counter set. Every field is a delta, never a total: a test that asserts a +// total is asserting the order the tests happened to run in. +type counterDelta struct { + queries uint64 + blocked uint64 + cached uint64 + errors uint64 + dropped uint64 + truncated uint64 + sent uint64 +} + +// applyDelta is the expected counter set after the delta, used to wait for the +// server to settle on it and to report where it differs. +func applyDelta(base TransportStats, d counterDelta) TransportStats { + return TransportStats{ + Transport: base.Transport, + Queries: base.Queries + d.queries, + Blocked: base.Blocked + d.blocked, + Cached: base.Cached + d.cached, + Errors: base.Errors + d.errors, + Dropped: base.Dropped + d.dropped, + Truncated: base.Truncated + d.truncated, + Sent: base.Sent + d.sent, + } +} + +// TestCountersFollowTheEngineOutcome is the main counter contract: one query, +// one answer, and exactly the counters that outcome implies — no more, no +// fewer. The full counter set is compared, not just the counter under test, +// because a counter that moves on every path is exactly the bug a +// single-counter assertion would miss. +func TestCountersFollowTheEngineOutcome(t *testing.T) { + cases := []struct { + name string + // prefix is the first label of the question name; the fake's recipe + // keys the outcome off it. + prefix string + qtype uint16 + // deltas is the expected move per transport. + deltas map[string]counterDelta + why string + }{ + { + prefix: "plain", qtype: dns.TypeA, + deltas: map[string]counterDelta{ + "udp": {queries: 1, sent: 1}, + "tcp": {queries: 1, sent: 1}, + "dot": {queries: 1, sent: 1}, + // The DoH path writes the body through net/http and never + // counts a send. Pinned exactly (sent stays 0) so the + // transport tab's "sent" column reads 0 next to a non-zero + // "queries" for DoH today, and so adding the bump later has + // to come with a decision about what it means. + "doh": {queries: 1}, + }, + why: "an ordinary answer: accepted and written back", + }, + { + prefix: "block", qtype: dns.TypeA, + deltas: map[string]counterDelta{ + "udp": {queries: 1, blocked: 1, sent: 1}, + "tcp": {queries: 1, blocked: 1, sent: 1}, + "dot": {queries: 1, blocked: 1, sent: 1}, + "doh": {queries: 1, blocked: 1}, + }, + why: "a blocklist hit is a normal answer to the client, so it counts as sent too", + }, + { + prefix: "cached", qtype: dns.TypeA, + deltas: map[string]counterDelta{ + "udp": {queries: 1, cached: 1, sent: 1}, + "tcp": {queries: 1, cached: 1, sent: 1}, + "dot": {queries: 1, cached: 1, sent: 1}, + "doh": {queries: 1, cached: 1}, + }, + why: "a cache hit is the number an operator uses to prove the cache is working", + }, + { + prefix: "fail", qtype: dns.TypeA, + deltas: map[string]counterDelta{ + "udp": {queries: 1, errors: 1, sent: 1}, + "tcp": {queries: 1, errors: 1, sent: 1}, + "dot": {queries: 1, errors: 1, sent: 1}, + "doh": {queries: 1, errors: 1}, + }, + why: "SERVFAIL is still an answer, so errors and sent both move", + }, + { + prefix: "drop", qtype: dns.TypeA, + deltas: map[string]counterDelta{ + // A drop counts as an error as well as a drop: HandlerStats + // documents Errors as "SERVFAIL, dropped or malformed". It is + // the only evidence a client-side timeout was a policy + // decision, so it must not be under-counted. + "udp": {queries: 1, errors: 1, dropped: 1}, + "tcp": {queries: 1, errors: 1, dropped: 1}, + "dot": {queries: 1, errors: 1, dropped: 1}, + // This is the regression pin: a DoH drop used to be counted + // as an error only, so the console's transport tab + // under-reported the drops an operator was told about in the + // aggregate. The body is still written (a DoH client waiting + // on a socket must not read a hang-up as a transport error), + // which is why sent is not bumped either: the write is + // net/http's, and the DoH path counts none. + "doh": {queries: 1, errors: 1, dropped: 1}, + }, + why: "a drop is a counter the client cannot see", + }, + { + prefix: "big", qtype: dns.TypeTXT, + deltas: map[string]counterDelta{ + // Only UDP can truncate: a stream transport sends the whole + // answer and lets the client reassemble it, so counting a + // truncation there would be a lie about what happened. + "udp": {queries: 1, truncated: 1, sent: 1}, + "tcp": {queries: 1, sent: 1}, + "dot": {queries: 1, sent: 1}, + "doh": {queries: 1}, + }, + why: "a 3 KiB answer to a 512-byte client is truncated, and that is a fact worth counting", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, nil) + before := h.srv.ByTransport() + want := map[string]TransportStats{} + for _, transport := range statTransports { + d, ok := tc.deltas[transport] + if !ok { + t.Fatalf("fixture: no expected delta for %s", transport) + } + want[transport] = applyDelta(before[transport], d) + } + for _, transport := range statTransports { + exchangeOnce(t, h, transport, question(tc.prefix+".counters.test.", tc.qtype)) + } + // The client can be answered before the last counter is bumped + // (the stream paths bump "sent" after the write returns), so the + // assertion waits for the settled state rather than for a + // duration. + waitForCounters(t, h, want) + }) + } +} + +// waitForCounters blocks until every counter set named in want is exactly +// matched. The comparison is exact in both directions: a counter that moved +// too far never matches, so double counting fails here rather than hiding +// behind a >= . want may name a subset of the transports, which is how a +// focused test states "this transport moved by this much". +func waitForCounters(t *testing.T, h *harness, want map[string]TransportStats) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + got := h.srv.ByTransport() + if countersMatch(got, want) { + return + } + if time.Now().After(deadline) { + t.Fatalf("counters did not settle within 3s:%s", counterDiff(got, want)) + } + time.Sleep(2 * time.Millisecond) + } +} + +func countersMatch(got, want map[string]TransportStats) bool { + for transport, w := range want { + if got[transport] != w { + return false + } + } + return true +} + +func counterDiff(got, want map[string]TransportStats) string { + var b strings.Builder + for _, transport := range statTransports { + if w, ok := want[transport]; ok && got[transport] != w { + fmt.Fprintf(&b, "\n %s: got %+v\n want %+v", transport, got[transport], w) + } + } + return b.String() +} + +// TestPerTransportCountersSumToTheAggregate: /status reports both the +// aggregate (HandlerStats) and the per-transport breakdown, and the console +// shows them side by side. If the aggregate ever counted something the +// breakdown does not — or double counted it — the two views of the same server +// would disagree, which is worse than either being wrong alone. The per- +// transport numbers are the ones the aggregate is built from, so summing them +// has to reproduce it exactly. +func TestPerTransportCountersSumToTheAggregate(t *testing.T) { + h := newHarness(t, nil) + // One of every outcome over every transport, so the sum is not trivially + // equal to a single counter. + names := []string{"plain.test.", "block.test.", "cached.test.", "fail.test.", "drop.test."} + for _, transport := range statTransports { + for _, name := range names { + exchangeOnce(t, h, transport, question(name, dns.TypeA)) + } + } + waitFor(t, 3*time.Second, "every query to be counted", func() bool { + return h.srv.Stats().Queries == uint64(len(statTransports)*len(names)) + }) + agg := h.srv.Stats() + byT := h.srv.ByTransport() + + var sum HandlerStats + for _, transport := range statTransports { + ts := byT[transport] + sum.Queries += ts.Queries + sum.Blocked += ts.Blocked + sum.Cached += ts.Cached + sum.Errors += ts.Errors + sum.Dropped += ts.Dropped + if ts.Queries != uint64(len(names)) { + t.Errorf("%s.Queries = %d, want %d", transport, ts.Queries, len(names)) + } + if ts.Blocked != 1 { + t.Errorf("%s.Blocked = %d, want 1", transport, ts.Blocked) + } + if ts.Cached != 1 { + t.Errorf("%s.Cached = %d, want 1", transport, ts.Cached) + } + // One SERVFAIL and one drop: HandlerStats documents Errors as + // including drops. + if ts.Errors != 2 { + t.Errorf("%s.Errors = %d, want 2 (one SERVFAIL, one drop)", transport, ts.Errors) + } + if ts.Dropped != 1 { + t.Errorf("%s.Dropped = %d, want 1", transport, ts.Dropped) + } + } + if agg.Queries != sum.Queries { + t.Errorf("Stats().Queries = %d, want the sum of the transports %d", agg.Queries, sum.Queries) + } + if agg.Blocked != sum.Blocked { + t.Errorf("Stats().Blocked = %d, want %d", agg.Blocked, sum.Blocked) + } + if agg.Cached != sum.Cached { + t.Errorf("Stats().Cached = %d, want %d", agg.Cached, sum.Cached) + } + if agg.Errors != sum.Errors { + t.Errorf("Stats().Errors = %d, want %d", agg.Errors, sum.Errors) + } + if agg.Dropped != sum.Dropped { + t.Errorf("Stats().Dropped = %d, want %d", agg.Dropped, sum.Dropped) + } + if agg.Queries == 0 || agg.Dropped == 0 { + t.Fatalf("the fixture counted nothing: %+v", agg) + } +} + +// TestDoHDropIsCountedAsDropped is the named pin for the DoH drop counter. It +// is separate from the table above because it is the one that was wrong: the +// DoH handler answered a dropped query with an empty body but bumped only +// "errors", so /status said the query was dropped while the transport tab +// said it was an error. An operator reading the transport tab to work out +// which listener is dropping traffic could not tell DoH from a resolver that +// was failing outright. +func TestDoHDropIsCountedAsDropped(t *testing.T) { + h := newHarness(t, nil) + before := h.srv.ByTransport()["doh"] + res := h.askDoHPOST(question("drop.test.", dns.TypeA)) + // The body is still required: unlike UDP, hanging up on an HTTP exchange + // reads to the client as a transport error and it retries elsewhere. + if len(res.Answer) != 0 { + t.Errorf("dropped query came back with %d answers: %v", len(res.Answer), res.Answer) + } + waitForCounters(t, h, map[string]TransportStats{ + "doh": applyDelta(before, counterDelta{queries: 1, errors: 1, dropped: 1}), + }) + if got := h.srv.Stats().Dropped; got != 1 { + t.Errorf("Stats().Dropped = %d, want 1 for a single DoH drop", got) + } + // And the other transports are untouched: a drop is counted against the + // transport it arrived on, not smeared over the server. + byT := h.srv.ByTransport() + for _, transport := range []string{"udp", "tcp", "dot"} { + if byT[transport] != (TransportStats{Transport: transport}) { + t.Errorf("a DoH query moved %s counters: %+v", transport, byT[transport]) + } + } +} + +// TestMalformedDatagramIsAnErrorAndNotAQuery is the aggregate half of +// TestUDPMalformedPacketIsCountedNotAnswered (which pins the per-transport +// counter and that nothing is written back). It matters separately because it +// makes Errors able to exceed Queries: a datagram that cannot be parsed is +// counted as an error, and never as a query, because the server never accepted +// a question out of it. Anything rendering one as a ratio of the other — the +// console, a dashboard, an alert — has to cope with that, and a server under +// a garbage flood is exactly when somebody looks. +func TestMalformedDatagramIsAnErrorAndNotAQuery(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if _, err := pc.WriteTo([]byte{0x01, 0x02, 0x03}, addr); err != nil { + t.Fatalf("write: %v", err) + } + waitFor(t, 2*time.Second, "the parse failure to be counted", func() bool { + return h.srv.Stats().Errors == 1 + }) + st := h.srv.Stats() + if st.Queries != 0 { + t.Errorf("Stats().Queries = %d, want 0: an unparsable datagram is not a query", st.Queries) + } + if st.Dropped != 0 { + t.Errorf("Stats().Dropped = %d, want 0: it is counted as an error, not as a drop", st.Dropped) + } + if n := len(h.fake.seen()); n != 0 { + t.Errorf("garbage reached the engine %d times: %+v", n, h.fake.seen()) + } + if got := h.srv.ByTransport()["udp"].Sent; got != 0 { + t.Errorf("udp.Sent = %d, want 0: nothing was written back", got) + } +} + +// TestInflightTracksTheHandler pins the one field of HandlerStats that is not a +// total. It is read by the health endpoint and the console's "in flight" gauge +// to tell a busy resolver from a stuck one, so it has to rise while a query +// is inside the pipeline and return to zero when the answer goes out. The +// second half is the part a broken implementation gets wrong: a gauge that +// ratchets up and never comes down turns the health check into a permanent +// alarm. +func TestInflightTracksTheHandler(t *testing.T) { + h := newHarness(t, nil) + release := holdOnce(t, h.fake) + conn, err := net.DialTimeout("tcp", h.addr("tcp"), 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + co := &dns.Conn{Conn: conn} + if err := co.WriteMsg(question("inflight.test.", dns.TypeA)); err != nil { + t.Fatalf("write: %v", err) + } + // The handler signals arrival before it parks on the gate, and the + // in-flight counter is incremented before that, so it is already non-zero + // here — no sleeping on "it has probably got there by now". + h.fake.waitArrived(t) + if got := h.srv.Stats().Inflight; got < 1 { + t.Fatalf("Stats().Inflight = %d while a query is inside the handler, want at least 1", got) + } + // A query that has not been answered is not a completed query yet. The + // counter that matters to an operator (Queries) must therefore still say + // nothing about it, or the rate graph counts work that is still running. + if got := h.srv.ByTransport()["tcp"].Queries; got != 0 { + t.Errorf("tcp.Queries = %d while the only query is still in flight, want 0", got) + } + + release() + res, err := co.ReadMsg() + if err != nil { + t.Fatalf("read: %v", err) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("A = %q, want 203.0.113.9", a) + } + waitFor(t, 2*time.Second, "the in-flight count to fall back to zero", func() bool { + return h.srv.Stats().Inflight == 0 + }) + ts := h.srv.ByTransport()["tcp"] + if ts.Queries != 1 || ts.Sent != 1 { + t.Errorf("tcp counters after the query finished = %+v, want queries=1 sent=1", ts) + } +} + +// TestByTransportAlwaysReportsTheSameFourTransports: the console's transport +// tab iterates whatever /status returns. A row that disappears when a listener +// is disabled, or a missing key when nothing has been served yet, turns a +// zero into a rendering bug at exactly the moment somebody is trying to read +// the numbers. All four are always present; a disabled or unused transport is +// a row of zeros. +func TestByTransportAlwaysReportsTheSameFourTransports(t *testing.T) { + check := func(t *testing.T, got map[string]TransportStats) { + t.Helper() + if len(got) != 4 { + t.Errorf("ByTransport() has %d entries (%+v), want 4", len(got), got) + } + for _, transport := range statTransports { + ts, ok := got[transport] + if !ok { + t.Errorf("ByTransport() is missing %q: %+v", transport, got) + continue + } + if ts.Transport != transport { + t.Errorf("ByTransport()[%q].Transport = %q, want the key echoed back", transport, ts.Transport) + } + } + } + // A server that has never served a query and has no engine at all. + fresh := New(Options{}) + if got := fresh.ByTransport(); len(got) != 4 { + t.Errorf("a server that never started reports %+v, want four zeroed transports", got) + } + check(t, fresh.ByTransport()) + + // A server with only the two plain listeners bound and one query served: + // the two disabled transports are still there, at zero. + h := newHarness(t, func(c *config.Config) { + c.DNS.DoT = config.DoT{Enabled: false} + c.DNS.DoH = config.DoH{Enabled: false} + }) + h.ask("udp", "unused-transport.test.", dns.TypeA) + got := h.srv.ByTransport() + check(t, got) + for _, transport := range []string{"dot", "doh"} { + if got[transport] != (TransportStats{Transport: transport}) { + t.Errorf("disabled transport %q = %+v, want an all-zero row", transport, got[transport]) + } + } + if got["udp"].Queries != 1 { + t.Errorf("udp.Queries = %d, want 1", got["udp"].Queries) + } +} diff --git a/internal/server/transport_test.go b/internal/server/transport_test.go new file mode 100644 index 0000000..b896229 --- /dev/null +++ b/internal/server/transport_test.go @@ -0,0 +1,265 @@ +package server + +import ( + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/miekg/dns" +) + +// TestAllFourListenersShareOneHandler is the package's headline claim: the +// four transports are thin shells around a single engine. A bug that made one +// of them bypass the handler (or answer from its own state) would show up here +// as a missing or duplicated call, so every transport is driven with a real +// client and checked for exactly one Handle, the right transport label, the +// right client address and the right answer on the wire. +func TestAllFourListenersShareOneHandler(t *testing.T) { + cases := []struct { + transport string + // tcp marks the transports that set engine.Request.TCP, i.e. the ones + // allowed to answer with a response larger than a UDP datagram. + wantTCP bool + }{ + {transport: "udp", wantTCP: false}, + {transport: "tcp", wantTCP: true}, + {transport: "dot", wantTCP: true}, + {transport: "doh", wantTCP: true}, + {transport: "doh-post", wantTCP: true}, + } + for _, tc := range cases { + t.Run(tc.transport, func(t *testing.T) { + h := newHarness(t, nil) + name := "host." + tc.transport + ".test." + res := h.ask(tc.transport, name, dns.TypeA) + + got := h.fake.only(t) + wantTransport := tc.transport + if wantTransport == "doh-post" { + wantTransport = "doh" + } + if got.transport != wantTransport { + t.Errorf("Request.Transport = %q, want %q", got.transport, wantTransport) + } + if got.name != dns.Fqdn(name) { + t.Errorf("question = %q, want %q", got.name, dns.Fqdn(name)) + } + if got.qtype != dns.TypeA { + t.Errorf("qtype = %d, want %d", got.qtype, dns.TypeA) + } + // The client is always loopback here, whatever the transport: that + // is what "the client address reached the engine" has to mean for a + // test that binds 127.0.0.1. + if got.remote.String() != "127.0.0.1" { + t.Errorf("Request.Remote = %v, want 127.0.0.1", got.remote) + } + if got.tcp != tc.wantTCP { + t.Errorf("Request.TCP = %v, want %v", got.tcp, tc.wantTCP) + } + // And the answer came back over the wire, not just out of the + // handler: same ID, the question echoed, one A record. + if len(res.Question) != 1 || res.Question[0].Name != dns.Fqdn(name) { + t.Errorf("reply question = %+v, want %s", res.Question, dns.Fqdn(name)) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("reply A = %q, want 203.0.113.9 (reply: %v)", a, res) + } + }) + } +} + +// TestAllFourListenersAreUpAndReported pins the Listeners() contract the admin +// API and the cluster node state read: one entry per bound listener, with the +// real address read back from the socket rather than the configured one (the +// tests bind port 0, so the two differ). +func TestAllFourListenersAreUpAndReported(t *testing.T) { + h := newHarness(t, nil) + seen := map[string]string{} + for _, l := range h.srv.Listeners() { + if !l.Enabled || !l.Running { + t.Errorf("listener %+v reported as not enabled/running after Start", l) + } + if l.Err != "" { + t.Errorf("listener %s reported error %q after a clean start", l.Transport, l.Err) + } + seen[l.Transport] = l.Addr + } + for _, want := range []string{"udp", "tcp", "dot", "doh"} { + addr, ok := seen[want] + if !ok { + t.Fatalf("no %s listener in %v", want, seen) + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Errorf("listener %s address %q: %v", want, addr, err) + continue + } + if host != "127.0.0.1" || port == "0" { + t.Errorf("listener %s address = %q, want a real 127.0.0.1 port", want, addr) + } + } + if got := len(h.srv.Listeners()); got != 4 { + t.Errorf("Listeners() has %d entries, want 4", got) + } +} + +// TestUDPListenerAnswersEveryDatagram is the raw-packet counterpart of the +// client-based test: it uses a net.PacketConn so a reply that the DNS client +// would have silently retried still has to arrive. The client address is the +// socket's local port, which is what makes it a different source address per +// exchange and therefore a check that the server writes back to the peer it +// read from rather than to whoever it read from first. +func TestUDPListenerAnswersEveryDatagram(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + for i := 0; i < 3; i++ { + q := question("raw.test.", dns.TypeA) + q.Id = uint16(0x1000 + i) + wire, err := q.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if _, err := pc.WriteTo(wire, addr); err != nil { + t.Fatalf("write: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 4096) + n, from, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("read %d: %v", i, err) + } + if from.String() != addr.String() { + t.Errorf("reply from %v, want %v", from, addr) + } + res := new(dns.Msg) + if err := res.Unpack(buf[:n]); err != nil { + t.Fatalf("unpack %d: %v", i, err) + } + if res.Id != q.Id { + t.Errorf("reply id = %d, want %d", res.Id, q.Id) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("reply A = %q, want 203.0.113.9", a) + } + } + waitFor(t, 2*time.Second, "three handler calls", func() bool { + return len(h.fake.seen()) == 3 + }) + for _, rec := range h.fake.seen() { + if rec.transport != "udp" { + t.Errorf("transport = %q, want udp", rec.transport) + } + } +} + +// TestDoHOverHTTP2 exercises the h2c wrapper over a TLS listener. The DoH +// listener advertises h2 in ALPN, so a real client (curl --http2, a browser) +// negotiates it; if the h2 path were broken the request would be answered over +// HTTP/1.1 or dropped, and neither is what an operator configured. +func TestDoHOverHTTP2(t *testing.T) { + h := newHarness(t, nil) + wire, err := question("h2.test.", dns.TypeA).Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + res, err := h.dohClient(true).Post(h.dohURL(h.base()), contentTypeDNS, strings.NewReader(string(wire))) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.Proto != "HTTP/2.0" { + t.Errorf("proto = %q, want HTTP/2.0", res.Proto) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", res.StatusCode, body) + } + if a := answerAddr(unpackDoH(t, body)); a != "203.0.113.9" { + t.Errorf("A = %q, want 203.0.113.9", a) + } + got := h.fake.only(t) + if got.transport != "doh" { + t.Errorf("transport = %q, want doh", got.transport) + } + if got.dohPath != h.base() { + t.Errorf("DoHPath = %q, want %q", got.dohPath, h.base()) + } +} + +// TestDoHHealthz is the endpoint an Ingress or a pod probe hits. It is +// registered on the same mux as the DNS handler, so a base path that shadows +// "/healthz" would be a real misconfiguration worth catching. +func TestDoHHealthz(t *testing.T) { + h := newHarness(t, nil) + res, err := h.dohClient(false).Get(h.dohURL("/healthz")) + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", res.StatusCode) + } + if len(h.fake.seen()) != 0 { + t.Errorf("healthz reached the engine: %+v", h.fake.seen()) + } +} + +// TestTCPPipelining pins RFC 1035 §4.2.2: one connection carries several +// queries with length prefixes, and a resolver that has a keep-alive +// connection open must be able to use it. A transport that closed after one +// query would still pass every single-query test. +func TestTCPPipelining(t *testing.T) { + h := newHarness(t, nil) + conn, err := net.DialTimeout("tcp", h.addr("tcp"), 3*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + co := &dns.Conn{Conn: conn} + for i := 0; i < 3; i++ { + q := question("pipelined.test.", dns.TypeA) + q.Id = uint16(0x2000 + i) + if err := co.WriteMsg(q); err != nil { + t.Fatalf("write %d: %v", i, err) + } + res, err := co.ReadMsg() + if err != nil { + t.Fatalf("read %d: %v", i, err) + } + if res.Id != q.Id { + t.Errorf("reply %d id = %d, want %d", i, res.Id, q.Id) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("reply %d A = %q, want 203.0.113.9", i, a) + } + } + waitFor(t, 2*time.Second, "three pipelined queries", func() bool { + return len(h.fake.seen()) == 3 + }) +} + +// answerAddr returns the first A record's address, or "". +func answerAddr(m *dns.Msg) string { + if m == nil { + return "" + } + for _, rr := range m.Answer { + if a, ok := rr.(*dns.A); ok { + return a.A.String() + } + } + return "" +} diff --git a/internal/server/udp_test.go b/internal/server/udp_test.go new file mode 100644 index 0000000..36590d7 --- /dev/null +++ b/internal/server/udp_test.go @@ -0,0 +1,365 @@ +package server + +import ( + "net" + "strings" + "testing" + "time" + + "github.com/miekg/dns" +) + +// readTick mirrors the unexported read deadline serveUDP uses. The test has to +// know it to be able to sleep past it; a copy here means a change to the +// product constant would quietly weaken this test rather than break it, so the +// value is asserted against the listener's behaviour below. +const readTick = 500 * time.Millisecond + +// TestUDPReadDeadlineIsResetEveryIteration is the regression test for the +// worst bug this package has had (AGENTS.md): serveUDP sets a read deadline so +// the loop notices cancellation, and a deadline set ONCE expires for good. +// After that the loop spins — every ReadFrom returns "i/o timeout" instantly — +// and never reads another packet for the life of the process. The listener +// looks healthy: it is bound, it is in the admin API, it logs nothing. +// +// The invariant pinned here: a packet that arrives more than one read-tick +// after the previous one is still answered. Send, sleep past the tick, send +// again, and both must come back. +func TestUDPReadDeadlineIsResetEveryIteration(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + ask := func(id uint16) { + t.Helper() + q := question("deadline.test.", dns.TypeA) + q.Id = id + wire, err := q.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if _, err := pc.WriteTo(wire, addr); err != nil { + t.Fatalf("write: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 4096) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("no answer to query %d: %v", id, err) + } + res := new(dns.Msg) + if err := res.Unpack(buf[:n]); err != nil { + t.Fatalf("unpack: %v", err) + } + if res.Id != id { + t.Errorf("reply id = %d, want %d", res.Id, id) + } + if a := answerAddr(res); a != "203.0.113.9" { + t.Errorf("reply A = %q, want 203.0.113.9", a) + } + } + + ask(0x3001) + // Longer than the read deadline, so a deadline that is not reset has + // certainly expired by the time the second packet lands. + time.Sleep(readTick + 250*time.Millisecond) + ask(0x3002) + + waitFor(t, 2*time.Second, "both queries at the engine", func() bool { + return len(h.fake.seen()) == 2 + }) +} + +// TestUDPTruncatesAnOversizedAnswer: a multi-kilobyte answer to a client that +// advertised nothing (the 512-byte floor) must come back as a question-only +// TC reply. Sending it whole would be silently dropped by the client and read +// as a dropped query, which is exactly the bug the engine suite found once +// already (STATUS.md: "the uncached path never truncated"). +func TestUDPTruncatesAnOversizedAnswer(t *testing.T) { + h := newHarness(t, nil) + res := h.askUDP(question("big.test.", dns.TypeTXT)) + if !res.Truncated { + t.Error("TC not set on a 3 KiB answer to a 512-byte client") + } + if len(res.Answer) != 0 { + t.Errorf("truncated reply carries %d answers, want none", len(res.Answer)) + } + if len(res.Question) != 1 || res.Question[0].Name != "big.test." { + t.Errorf("truncated reply question = %+v, want big.test.", res.Question) + } + if got := h.srv.load("udp", "truncated"); got != 1 { + t.Errorf("udp.truncated = %d, want 1", got) + } + if err := h.fake.lastPackErr(); err != nil { + t.Fatalf("the fake's big answer did not pack, so this test is measuring a fixture failure: %v", err) + } +} + +// TestUDPUsesTheAdvertisedEDNSSize: with EDNS0 the client says how much it can +// take, and the server must honour it — the same answer, delivered whole. +func TestUDPUsesTheAdvertisedEDNSSize(t *testing.T) { + h := newHarness(t, nil) + m := question("big.test.", dns.TypeTXT) + m.SetEdns0(4096, false) + res := h.askUDP(m) + if res.Truncated { + t.Error("3 KiB answer truncated for a client that advertised 4096") + } + if len(res.Answer) != 1 { + t.Fatalf("reply has %d answers, want the whole TXT", len(res.Answer)) + } + if o := res.IsEdns0(); o == nil { + t.Error("reply has no OPT even though the query had one") + } +} + +// TestUDPCapsTheAdvertisedEDNS0Buffer is the amplification guard: a client may +// claim 64 KiB, and the server rewrites the request to 4096 before the engine +// ever sees it, so no answer is built for a buffer nobody will accept. +func TestUDPCapsTheAdvertisedEDNS0Buffer(t *testing.T) { + h := newHarness(t, nil) + m := question("edns.test.", dns.TypeA) + m.SetEdns0(65535, false) + h.askUDP(m) + + got := h.fake.only(t) + if got.ednsSize == 0 { + t.Fatal("the engine saw no EDNS0 at all") + } + if got.ednsSize > 4096 { + t.Errorf("engine saw an advertised buffer of %d, want it capped at 4096", got.ednsSize) + } + if got.ednsSize != 4096 { + t.Errorf("advertised buffer = %d, want the 4096 cap", got.ednsSize) + } +} + +// TestFitUDP covers the packing helper directly, where the buffer arithmetic is +// visible without a socket in the way. The three cases are the three answers +// the function can give: pass through, truncate to the question, or refuse to +// answer at all. +func TestFitUDP(t *testing.T) { + t.Run("small answer passes through untouched", func(t *testing.T) { + s := &Server{} + q := question("small.test.", dns.TypeA) + res := answerA(newReply(q)) + out := s.fitUDP(res, q, 512) + want, err := res.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if string(out) != string(want) { + t.Errorf("fitUDP rewrote an answer that already fit (%d vs %d bytes)", len(out), len(want)) + } + if got := s.load("udp", "truncated"); got != 0 { + t.Errorf("truncated = %d for an answer that fit", got) + } + }) + + t.Run("oversized answer becomes a question-only TC reply", func(t *testing.T) { + s := &Server{} + q := question("big.test.", dns.TypeTXT) + res := bigAnswer(newReply(q)) + full, err := res.Pack() + if err != nil { + t.Fatalf("the big answer must pack for this test to mean anything: %v", err) + } + if len(full) <= 512 { + t.Fatalf("fixture is only %d bytes, it cannot test truncation", len(full)) + } + out := s.fitUDP(res, q, 512) + if len(out) > 512 { + t.Errorf("truncated reply is %d bytes, over the 512 floor", len(out)) + } + got := new(dns.Msg) + if err := got.Unpack(out); err != nil { + t.Fatalf("unpack truncated reply: %v", err) + } + if !got.Truncated { + t.Error("TC not set") + } + if len(got.Answer) != 0 { + t.Errorf("truncated reply carries %d answers", len(got.Answer)) + } + if len(got.Question) != 1 || got.Question[0].Name != q.Question[0].Name { + t.Errorf("question = %+v, want %v", got.Question, q.Question) + } + if got.Rcode != res.Rcode { + t.Errorf("rcode = %d, want the answer's %d", got.Rcode, res.Rcode) + } + if s.load("udp", "truncated") != 1 { + t.Errorf("truncated counter = %d, want 1", s.load("udp", "truncated")) + } + }) + + t.Run("an advertised EDNS0 buffer is the limit", func(t *testing.T) { + s := &Server{} + q := question("big.test.", dns.TypeTXT) + q.SetEdns0(4096, false) + res := bigAnswer(newReply(q)) + out := s.fitUDP(res, q, 512) + got := new(dns.Msg) + if err := got.Unpack(out); err != nil { + t.Fatalf("unpack: %v", err) + } + if got.Truncated { + t.Error("3 KiB answer truncated against a 4096-byte buffer") + } + if len(got.Answer) != 1 { + t.Errorf("answers = %d, want 1", len(got.Answer)) + } + if o := got.IsEdns0(); o == nil || o.UDPSize() != 4096 { + t.Errorf("truncated reply lost the OPT size: %+v", o) + } + }) + + t.Run("an unadvertised buffer below 512 cannot raise the floor", func(t *testing.T) { + s := &Server{} + q := question("big.test.", dns.TypeTXT) + // 300 is below the 512 floor RFC 1035 guarantees; using it as the + // limit would produce a reply no client is obliged to read. + q.SetEdns0(300, false) + res := bigAnswer(newReply(q)) + out := s.fitUDP(res, q, 512) + if len(out) > 512 { + t.Errorf("reply is %d bytes, over the 512 floor", len(out)) + } + got := new(dns.Msg) + if err := got.Unpack(out); err != nil { + t.Fatalf("unpack: %v", err) + } + if !got.Truncated { + t.Error("TC not set") + } + }) + + t.Run("an answer that will not pack is dropped, not truncated", func(t *testing.T) { + s := &Server{} + q := question("unpackable.test.", dns.TypeTXT) + res := new(dns.Msg) + res.SetReply(q) + // A character-string over 255 bytes: miekg/dns v1.1.73 reports its + // length as zero, so Pack fails. Dropping is the only safe answer — + // a half-written datagram is worse than none. + res.Answer = []dns.RR{&dns.TXT{ + Hdr: dns.RR_Header{Name: q.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, + Txt: []string{strings.Repeat("x", 300)}, + }} + if _, err := res.Pack(); err == nil { + t.Fatal("fixture packs; this case is no longer testing the pack failure") + } + if out := s.fitUDP(res, q, 512); out != nil { + t.Errorf("fitUDP returned %d bytes for an unpackable answer, want nil", len(out)) + } + }) +} + +// TestUDPDropSendsNothing: a dropped query is answered with silence, which on +// UDP means no datagram at all. The counter is the only evidence a client-side +// timeout was a policy decision. +func TestUDPDropSendsNothing(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + wire, err := question("drop.test.", dns.TypeA).Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + if _, err := pc.WriteTo(wire, addr); err != nil { + t.Fatalf("write: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + buf := make([]byte, 4096) + if n, _, err := pc.ReadFrom(buf); err == nil { + t.Errorf("a dropped query was answered with %d bytes", n) + } + waitFor(t, 2*time.Second, "the drop at the engine", func() bool { return len(h.fake.seen()) == 1 }) + if got := h.srv.load("udp", "dropped"); got != 1 { + t.Errorf("udp.dropped = %d, want 1", got) + } + if got := h.srv.load("udp", "queries"); got != 1 { + t.Errorf("udp.queries = %d, want 1", got) + } +} + +// TestUDPMalformedPacketIsCountedNotAnswered: an unparsable datagram has no ID +// and no question, so there is nothing legal to reply with. It must be counted +// and dropped — a server that echoes a FORMERR to arbitrary garbage is an +// amplifier. +func TestUDPMalformedPacketIsCountedNotAnswered(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if _, err := pc.WriteTo([]byte{0x01, 0x02, 0x03}, addr); err != nil { + t.Fatalf("write: %v", err) + } + waitFor(t, 2*time.Second, "the parse failure", func() bool { return h.srv.load("udp", "errors") == 1 }) + if n := len(h.fake.seen()); n != 0 { + t.Errorf("garbage reached the engine %d times", n) + } + _ = pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + buf := make([]byte, 4096) + if n, _, err := pc.ReadFrom(buf); err == nil { + t.Errorf("garbage was answered with %d bytes", n) + } +} + +// TestUDPQuestionlessMessageReachesTheEngine: a parseable message with no +// question is answerable with FORMERR, and servePacket deliberately hands it +// to the engine rather than dropping it — the comment in the code says so and +// it is the difference between a client that learns why it was refused and a +// client that times out. +func TestUDPQuestionlessMessageReachesTheEngine(t *testing.T) { + h := newHarness(t, nil) + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer pc.Close() + addr, err := net.ResolveUDPAddr("udp", h.addr("udp")) + if err != nil { + t.Fatalf("resolve: %v", err) + } + // Header only: valid DNS, qdcount 0. + msg := []byte{0x12, 0x34, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0} + if _, err := pc.WriteTo(msg, addr); err != nil { + t.Fatalf("write: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 4096) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("no answer to a questionless query: %v", err) + } + res := new(dns.Msg) + if err := res.Unpack(buf[:n]); err != nil { + t.Fatalf("unpack: %v", err) + } + if res.Rcode != dns.RcodeFormatError { + t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode]) + } + if got := h.fake.only(t); got.name != "" { + t.Errorf("engine saw question %q, want none", got.name) + } +} diff --git a/internal/zonefile/example_test.go b/internal/zonefile/example_test.go new file mode 100644 index 0000000..c15e6dd --- /dev/null +++ b/internal/zonefile/example_test.go @@ -0,0 +1,81 @@ +package zonefile + +import ( + "path/filepath" + "slices" + "strings" + "testing" +) + +// nameTypeRdata renders every record as "name TYPE rdata", skipping the line +// number and the TTL. The shipped examples are edited by hand, so pinning the +// exact record set is enough; pinning line numbers would make a comment change +// look like a parser bug. +func nameTypeRdata(t *testing.T, z *Zone) []string { + t.Helper() + out := make([]string, 0, len(z.Records)) + for _, rec := range z.Records { + parts := strings.SplitN(rec.RR.String(), "\t", 5) + if len(parts) != 5 { + t.Fatalf("record on line %d does not render as 5 tab-separated fields: %q", + rec.Line, rec.Text()) + } + out = append(out, parts[0]+" "+parts[3]+" "+parts[4]) + } + return out +} + +// TestExampleLocalZoneIsOriginIndependent parses deploy/local.example.zone the +// way the product parses it — with the origin taken from the file name, which +// for local.example.zone is "local." — and pins that every name in the example +// is fully qualified anyway. +// +// This is not a style test. The origin a master file is read with is its base +// name, so a relative owner name silently becomes .local. and the zone +// answers for names nobody asked about. The example is what an operator copies, +// and it used to mix relative owners with absolute ones: a file that looked +// right and matched nothing. +func TestExampleLocalZoneIsOriginIndependent(t *testing.T) { + path := filepath.Join("..", "..", "deploy", "local.example.zone") + if got, want := OriginFromPath(path), "local."; got != want { + t.Fatalf("OriginFromPath(%q) = %q, want %q — the example depends on this origin", + path, got, want) + } + + z, err := ParseFile(path, OriginFromPath(path)) + if err != nil { + t.Fatalf("ParseFile(%s): %v", path, err) + } + got := nameTypeRdata(t, z) + want := []string{ + "lan. SOA ns1.lan. hostmaster.lan. 2024010101 3600 600 604800 300", + "lan. NS ns1.lan.", + "lan. NS ns2.lan.", + "ns1.lan. A 192.168.1.2", + "ns2.lan. A 192.168.1.3", + "router.lan. A 192.168.1.1", + "nas.lan. A 192.168.1.10", + "nas.lan. AAAA fd00::10", + "*.lan. A 192.168.1.50", + "*.internal.lan. A 10.0.0.50", + "_ntp._udp.lan. SRV 0 0 123 ns1.lan.", + "_ldap._tcp.lan. SRV 10 0 389 nas.lan.", + `_dmarc._domain.lan. TXT "v=DMARC1; p=reject; rua=mailto:dmarc.lan"`, + `_lan. TXT "internal network, do not use on the open internet"`, + } + if !slices.Equal(got, want) { + t.Errorf("records mismatch\n got: %q\nwant: %q", got, want) + } + if z.DefaultTTL != 300 { + t.Errorf("DefaultTTL = %d, want the $TTL 300", z.DefaultTTL) + } + for _, rec := range z.Records { + if !strings.HasSuffix(rec.Name, ".") { + t.Errorf("record %q is not fully qualified; the example is read with the "+ + "origin %q derived from the file name", rec.Name, z.Origin) + } + } + if z.SOA == nil || z.SOA.Serial != 2024010101 { + t.Errorf("SOA = %v, want the serial 2024010101", z.SOA) + } +} diff --git a/web/README.md b/web/README.md index d6af7e3..cbd1d65 100644 --- a/web/README.md +++ b/web/README.md @@ -1,32 +1,63 @@ -# React + TypeScript + Vite +# The console -This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. +Vite + React + TypeScript + Tailwind, built into `internal/web/dist` and served +by the Go binary from an `//go:embed`. There is no separate deployment: the +binary *is* the console. -Currently, two official plugins are available: +## Day-to-day -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the Oxlint configuration - -If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: - -```json -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "options": { - "typeAware": true - }, - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} +```sh +make web-check # npx tsc -b --noEmit && npm run build (what CI runs) +make web-dev # Vite dev server, proxying /api to 127.0.0.1:8080 +make web # production build into internal/web/dist ``` -See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. +For `make web-dev` to show anything, a `fancy-dns` has to be listening on +127.0.0.1:8080 with the API key the console sends. `deploy/config.example.yaml` +binds there, but on port 53; rewrite the ports (or use `make smoke`, which does +it for you) before starting it. + +Two traps that have cost time, both from `AGENTS.md`: + +- **`tsc -b` replays cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`. + After a large edit `rm -f node_modules/.tmp/*.tsbuildinfo`, or you will read + errors that no longer exist and miss ones that do. +- **`npm run build` empties `internal/web/dist` and deletes its `.gitkeep`.** + Restore it (`git checkout -- internal/web/dist/.gitkeep`). The Go build still + works while the directory is empty, so a console rebuild in the middle of + someone else's `go build` is not fatal — but the commit is. + +## The API contract + +`src/lib/types.ts` is a **hand-written mirror** of the Go `json` tags in +`internal/api` and `internal/app/hooks.go`. `apiFetch` ends in +`JSON.parse(text) as T`, so the compiler checks the console against its own +declarations and nothing else: a field that loses its `json` tag, or a response +a struct never had, shows up as an empty table in a browser and nowhere else. +That is not hypothetical — a round of this found the Sources table, the ACL +group cards and both Save buttons broken exactly that way. + +So, when you touch either side: + +- **Go**: add or keep the `json` tag. `internal/app` has contract tests that + assert the exact key set of every admin endpoint; they are the spec, and a + rename fails them. +- **Console**: change the interface in the same commit. If the server is wrong, + fix the server — do not "adapt" in the client, and do not paper a mismatch + over with `as any`, an optional field or a `?? 0` default. Those casts are + precisely what hides drift. +- Never rename a field on one side only. The dashboard, the ServiceMonitor + queries and the Grafana panels are consumers of the same JSON. + +The durable fix is to generate `types.ts` from the Go structs, or to publish an +OpenAPI document from the server and generate from that. It is in the plan at +the end of `STATUS.md`. + +## What is checked + +`npx tsc -b --noEmit` (strict, with `noUnusedLocals`) and `oxlint` (two rules: +`react/rules-of-hooks` and `react/only-export-components`). There is **no test +runner** and no component test — the console's correctness rests entirely on its +types, which are only as good as the mirror above. Adding vitest and a handful +of tests for `lib/format.ts` (the percent/latency/duration formatters, which +have already shipped a units bug) is cheap and worth doing.