half broken
build / build-push (push) Failing after 23s

This commit is contained in:
developer committed 2026-09-27 17:17:53 +00:00
1 parent cc7466823b
commit e8c5b414d9
44 files changed
+7649 -344

No files matched your search

Binary file not shown.
+300 -19
View File
@@ -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"
}
+21 -3
View File
@@ -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
+23 -10
View File
@@ -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`.
+30 -2
View File
@@ -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 '<!doctype html' && echo "embedded"; \
echo "==> 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
+35 -2
View File
@@ -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 `*.<name>`, 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 `*.<name>`, 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.
+315 -211
View File
@@ -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`.
+3 -2
View File
@@ -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
+20 -11
View File
@@ -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"
+18 -10
View File
@@ -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
+3 -1
View File
@@ -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
+12
View File
@@ -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=
+28 -8
View File
@@ -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
+8 -7
View File
@@ -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.
-7
View File
@@ -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 {
+213
View File
@@ -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")
}
}
+129
View File
@@ -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)
}
}
+136
View File
@@ -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)
}
}
+356
View File
@@ -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)
}
}
+492
View File
@@ -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)
}
})
}
+243
View File
@@ -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")
}
}
+143
View File
@@ -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)
}
}
+31 -11
View File
@@ -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) }
+905
View File
@@ -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 ""
}
+270
View File
@@ -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
}
+148
View File
@@ -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")
}
})
}
+116
View File
@@ -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")
}
})
}
+13 -2
View File
@@ -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())
}
+4 -5
View File
@@ -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 {
+4
View File
@@ -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.
+170
View File
@@ -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")
}
})
}
+386
View File
@@ -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)) }
+150
View File
@@ -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)
}
}
+383
View File
@@ -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
},
}
}
+581
View File
@@ -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{}
+232
View File
@@ -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) }
+27 -6
View File
@@ -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)
}
+379
View File
@@ -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)
}
}
+7
View File
@@ -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"`
}
+546
View File
@@ -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)
}
}
+265
View File
@@ -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 ""
}
+365
View File
@@ -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)
}
}
+81
View File
@@ -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 <name>.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)
}
}
+58 -27
View File
@@ -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.