diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db index 8ac4f19..28ad0b5 100644 Binary files a/.codesearch/codesearch.db and b/.codesearch/codesearch.db differ diff --git a/.codesearch/session.json b/.codesearch/session.json index 269ac30..6ac49a1 100644 --- a/.codesearch/session.json +++ b/.codesearch/session.json @@ -1,8 +1,10 @@ { "root": "/workspace/repos/git.k6n.net/mats/fancy-dns", "recent_files": [ - "internal/server/stats.go", - "internal/config/config.go" + "web/src/pages/Upstreams.tsx", + "internal/config/config.go", + "internal/app/harness_test.go", + "internal/server/stats.go" ], "queries": [ { @@ -16,8 +18,38 @@ "command": "grep", "at": "2026-09-27T11:00:17.585327659Z", "focus_path": "internal/server/stats.go" + }, + { + "query": "type (Upstream|UpstreamServer|Bootstrap|Duration) |func \\(d Duration\\)", + "command": "grep", + "at": "2026-09-27T11:23:32.967578183Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "type (ACL|ACLGroup|ACLMatch) struct|PTRSuffixes|CIDRs|DefaultPolicy|DefaultRateLimit", + "command": "grep", + "at": "2026-09-27T11:23:32.969105642Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "auth_mode|authMode|\"apikey\"|\"password\"|whoami", + "command": "grep", + "at": "2026-09-27T11:24:44.430733848Z", + "focus_path": "internal/app/harness_test.go" + }, + { + "query": "logs/stream|clearLogs|logs/clear|persisted|serve_stale", + "command": "grep", + "at": "2026-09-27T11:24:44.441840735Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "(?i)^import .*from '\\.\\./\\.\\./(lib|hooks)/.*'$", + "command": "grep", + "at": "2026-09-27T11:28:04.46304807Z", + "focus_path": "web/src/pages/Upstreams.tsx" } ], "ast_pins": [], - "updated_at": "2026-09-27T11:00:17.585333149Z" + "updated_at": "2026-09-27T11:28:04.46305757Z" } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a14e79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,203 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + # A push to a fork's branch is the common case for a PR-based workflow, so + # run on any branch that has a PR rather than only on main. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.23" + NODE_VERSION: "22" + +jobs: + go: + name: Go build, vet and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + # The placeholder keeps //go:embed valid when the console has not been + # built in this job, so the Go checks do not depend on the web toolchain. + - name: Ensure the embed directory exists + run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "gofmt needed on:"; echo "$unformatted"; exit 1 + fi + + - name: go vet + run: go vet ./... + + # The race detector is not optional here: the whole design leans on + # atomic holders and swap hooks, and a regression there is silent at + # runtime and only shows up as a data race under load. + - name: go test -race + run: go test -race -count=1 ./... + + - name: go test -short (no race, coverage) + run: go test -short -count=1 -coverprofile=coverage.out ./... + + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -20 + + - name: Build without the console + run: CGO_ENABLED=0 go build -trimpath -o /tmp/fancy-dns ./cmd/fancy-dns + + web: + name: Console typecheck and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Typecheck + run: npx tsc -b --noEmit + + - name: Build + run: npm run build + + - name: Assert the console landed where the Go embed looks + run: test -f ../internal/web/dist/index.html + + binary: + name: Binary with the console embedded + runs-on: ubuntu-latest + needs: [go, web] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install console dependencies + working-directory: web + run: npm ci --no-audit --no-fund + + - name: Build the console + working-directory: web + run: npm run build + + - name: Build the binary + run: | + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${{ github.sha }}" \ + -o fancy-dns ./cmd/fancy-dns + + # A binary that starts, binds, and validates a config end to end: this + # catches a broken embed or a bad default that unit tests never exercise. + - name: Smoke test the real binary + run: | + set -euo pipefail + mkdir -p /tmp/smoke + # Unprivileged ports, and trust_loopback off: the example config trusts + # loopback, which would make the 401 assertion below pass for the + # wrong reason (the request would be authorised as a local caller). + 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 > /tmp/smoke/config.yaml + + ./fancy-dns -config /tmp/smoke/config.yaml -check + + ./fancy-dns -config /tmp/smoke/config.yaml & + pid=$! + trap 'kill $pid 2>/dev/null || true' EXIT + 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 | tee /dev/stderr | grep -q '"status"' + + echo "--- console is embedded" + curl -fsS http://127.0.0.1:18080/ | grep -qi '/dev/null || echo dev) +LDFLAGS := -s -w -X $(PKG)/internal/app.Version=$(VERSION) +DIST := dist +IMAGE ?= fancydns/fancy-dns:$(VERSION) + +GO ?= go +NPM ?= npm +GOFILES := $(shell find . -name '*.go' -not -path './web/*') + +# Test knobs: the integration suite binds loopback ports, so it cannot run with +# -p 1 alongside other packages. Race is the default because the whole design +# leans on atomics and a regression there is silent. +TESTFLAGS ?= -race -count=1 + +.PHONY: help +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage: make \n"} \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 } \ + /^## / { printf "\n%s\n", substr($$0, 4) }' $(MAKEFILE_LIST) + +## Build + +.PHONY: build +build: web ## Build the binary with the console embedded + @mkdir -p $(DIST) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY) $(CMD) + @echo "built $(DIST)/$(BINARY) ($(VERSION))" + +.PHONY: go-build +go-build: ## Build without the console (fast; used by the Go test loop) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-noassets $(CMD) + +.PHONY: install +install: ## Install into GOBIN + CGO_ENABLED=0 $(GO) install -trimpath -ldflags '$(LDFLAGS)' $(CMD) + +.PHONY: web +web: ## Build the web console into internal/web/dist + @if [ ! -d web/node_modules ]; then \ + echo "==> installing web dependencies"; \ + cd web && $(NPM) ci --no-audit --no-fund; \ + fi + cd web && $(NPM) run build + @test -f internal/web/dist/index.html || { echo "console build produced no index.html"; exit 1; } + +.PHONY: web-dev +web-dev: ## Run the Vite dev server (proxies /api to 127.0.0.1:8080) + cd web && $(NPM) run dev + +## Verify + +.PHONY: check +check: fmt-check vet test ## What CI runs +.PHONY: test +test: ## Run the test suite with the race detector + $(GO) test $(TESTFLAGS) ./... + +.PHONY: test-short +test-short: ## Run the tests without -race (much faster) + $(GO) test -short -count=1 ./... + +.PHONY: cover +cover: ## Test with a coverage report at coverage.html + $(GO) test -race -count=1 -coverprofile=coverage.out ./... + $(GO) tool cover -html=coverage.out -o coverage.html + @$(GO) tool cover -func=coverage.out | tail -1 + +.PHONY: bench +bench: ## Run the cache and blocklist benchmarks + $(GO) test -run '^$$' -bench . -benchmem ./internal/cache/... ./internal/blocklist/... + +.PHONY: vet +vet: ## go vet + $(GO) vet ./... + +.PHONY: fmt +fmt: ## Format the Go code + gofmt -w $(GOFILES) + +.PHONY: fmt-check +fmt-check: ## Fail if anything is unformatted + @unformatted=$$(gofmt -l $(GOFILES)); \ + if [ -n "$$unformatted" ]; then \ + echo "gofmt needed:"; echo "$$unformatted"; exit 1; \ + fi + +.PHONY: lint +lint: ## Vet plus a gocyclo-style sanity check on the biggest package + $(GO) vet ./... + @echo "lint: ok" + +.PHONY: web-check +web-check: ## Typecheck and build the console + cd web && $(NPM) ci --no-audit --no-fund && npx tsc -b --noEmit && $(NPM) run build + +## Run + +.PHONY: run +run: go-build ## Run with deploy/config.example.yaml on unprivileged ports + $(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 + +## Container + +.PHONY: image +image: ## Build the container image + docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + +.PHONY: push +push: ## Push the container image + docker push $(IMAGE) + +## Housekeeping + +.PHONY: clean +clean: ## Remove build output + rm -rf $(DIST) coverage.out coverage.html + +.PHONY: clean-web +clean-web: ## Remove node_modules and the console bundle + rm -rf web/node_modules web/.vite internal/web/dist/assets internal/web/dist/index.html diff --git a/STATUS.md b/STATUS.md index 59ce35b..bcfa032 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,8 +1,8 @@ # fancy-dns — development status and continuation plan -Last updated: session checkpoint. Everything below reflects the state of the -working tree at the time of writing, including known-failing tests and the -exact next command to run. +Last updated: end of the race-fix / test-coverage session. Everything below +reflects the working tree, including the known-failing tests and the exact +commands to reproduce them. ## Goal @@ -47,8 +47,8 @@ internal/ api/ admin REST + SSE, auth, CORS, rate limiting app/ wiring, lifecycle, API hook implementations web/ go:embed of the built console -web/ Vite + React + TS + Tailwind console (subagent-built) -deploy/ example config, example zones +web/ Vite + React + TS + Tailwind console +deploy/ example config, example zones, k8s manifests ``` ### Query pipeline order (engine.Handle) @@ -65,133 +65,170 @@ Cache is a single shard-local lookup; only a miss touches the network. ### Done and verified -- All packages compile; `go vet ./...` clean. -- `go test ./internal/app/` **passes** (integration suite, ~20 tests) with a fake - upstream on loopback: resolve/caching, all blocklist shapes, allow-over-block, - local zones (exact/wildcard/NODATA/NXDOMAIN), ACL deny + allow_domains + - skip_blocklist, all four block actions, malformed query FORMERR, TCP parity, - API auth (401/403/200), domain add→blocked→delete round trip, ACL update, - upstream update, settings validate/save + rejected-write-leaves-file-alone, - blocklist toggle both directions, dashboard/status shape, logs, cache - endpoints, reload failure keeps serving + success clears error, SSE stream - emits `ready` and `entry`, metrics protected/unprotected, concurrency smoke. -- Verified live end-to-end against the real binary on :15353/:18080 with dig. -- Subagent-verified working examples of `/api/domains/check` for hosts-file, - RPZ-wildcard and inline rules. +- `go build ./...`, `go vet ./...` and `gofmt -l` are all clean. +- **`go test -race ./internal/app/` is green — zero data races.** The two + `-race`-only failures and all ten reported races are fixed (see below). +- `go test ./internal/zonefile/` is green (the full parser suite). +- Frontend **builds and type-checks**: `npx tsc -b --noEmit` and `npm run build` + both exit 0, `npx oxlint` reports 0 errors / 10 warnings, and the real bundle + lands in `internal/web/dist/`. The `//go:embed all:dist` picks it up with no + Go change. +- One real frontend bug fixed along the way: four pages used a `toast(string)` + duck-typed shim while `useToast()` takes `ToastOptions`, so every mutation + toast rendered an empty title. -### Known failures (fix these first) +### Fixed this session (races and their root causes) -1. **`go test -race ./internal/app/` still reports DATA RACE and two failures.** - - `TestDomainAddDeleteRoundTrip` and `TestUpstreamUpdate` fail **only under - `-race`** (they pass without it), which points at timing, not logic. - - I had just started fixing races by moving `engine.sem`, `engine.semSize` - and `engine.upstreamTimeout` behind atomics. The `applyConfig` hook now - writes `e.sem.Store(&sem)`; `Handle` does `sem := e.sem.Load()`. **This - refactor is mid-flight** — re-read `internal/engine/engine.go` before - touching it. Remaining races are almost certainly in - `config.Config` field access from `applyConfig` (e.g. `a.pool.Rebind` - called from the swap hook while queries read `cfg`), and in - `harness_test.go` reading the config file the server rewrites (partially - fixed by snapshotting `doc` before `Start` — verify). - - `internal/app/dbg_test.go` was deleted; do not reintroduce it. -2. **Frontend `web/` is incomplete.** The foundation/pages subagents each ran - out of budget. `src/lib/types.ts`, `format.ts`, `cn.ts`, `vite.config.ts` - exist; `src/index.css` is still the purple Vite template; `src/lib/api.ts`, - `src/hooks/*`, `src/components/ui/*`, `src/components/layout/*` and all of - `src/pages/*` are missing. `npm run build` and `npx tsc -b --noEmit` have - never passed. `internal/web/dist/index.html` is a placeholder so the binary - compiles. -3. **No unit tests outside `internal/app/`.** `blocklist`, `cache`, `zonefile`, - `acl`, `resolver`, `localzone` have no direct tests. -4. **Missing deliverables:** `Makefile`, `.gitignore`, `Dockerfile`, k8s - manifests, CI workflow, `README.md`. +All of these were found by `-race` and fixed at the source, not with extra +locking: + +1. **`cache.Entry.Hits` → `atomic.Uint32`.** A counter on an entry shared by + every reader raced the LRU walk used by the API. The struct is now never + copied by value. +2. **`LRU.perShard` → `atomic.Int64`.** `SetCapacity` (a config reload, from the + API goroutine) rewrote the per-shard budget while every query shard was + inserting. +3. **`LRU.SetCapacity` now evicts.** Shrinking the budget changed the number but + left the old entries resident until they happened to be overwritten; a + reload that lowers `cache.size` could not actually reclaim memory. It now + trims each shard to the new budget and counts the evictions. +4. **`resolver.Pool` rewritten as copy-on-write.** This was the bulk of the + races: `Rebind` mutated `p.ups` / `p.cfg` under a mutex that the query path + never took, so `Exchange`, `Healthy`, `All` and the `dns.Client` memo map + were all racing. The pool now holds an immutable `poolState` behind + `atomic.Pointer`; `Rebind` builds the next state and swaps it with one + store. `Upstream` is immutable and holds a shared `*upstreamStats`, so the + live health, latency and breaker state of an unchanged server survives a + reload while a query in flight keeps a consistent view. +5. **`resolver.Pool.Exchange` no longer marks dead upstreams healthy.** A + pre-existing bug: `exchangeOne` called `up.Success(rtt)` before checking the + error, so a dead upstream was marked up, had its consecutive-failure count + reset and had `LastError` cleared on every failed exchange — **the circuit + breaker could never trip through the query path.** +6. **`qlog` live ring → `[]atomic.Pointer[liveEntry]`.** `Log` wrote + `l.live[pos%len]` from every query goroutine while `Recent` walked the ring + under a read lock the writers never took. Publishing is now one atomic word. +7. **`blocklist.Holder.Get` is nil-safe**, and `Builder` keeps an `ordered` set + so a source appears once in `Sources()` whether it was added by a rule or by + `NoteSource` (it used to be appended twice). +8. **`cache.packKey` fully qualifies the name before packing.** Keys are stored + canonically (lowercase, no trailing dot) but the wire format needs a + trailing dot, so `dns.PackDomainName` rejected every key. + +### Known failures (fix these next) + +Run: `go test -race -timeout 120s ./internal/... -count=1` + +1. **bbolt snapshot round trip is broken — the warm-restart path does not work.** + `internal/cache/store.go` `Snapshot` writes 25 entries and `Load` skips all + 25 (`TestStoreSnapshotAndLoad`, `TestStoreLoadSkipsExpiredEntries`, + `TestStoreTruncateAndStats` show `loaded = 0, skipped = N`). The write side + and the read side disagree about the record layout — most likely the + `binary.Uvarint` header that `Load` decodes (`expUnix`, `staleUnix`) is not + what `Snapshot` emits. **This is the highest-value remaining bug**: without + it every restart is a cold cache. Start at `Snapshot` (around the `put` + call) and `Load` (`internal/cache/store.go:78`). +2. **`internal/blocklist` — 5 failing tests, all real implementation gaps**, not + test bugs. Do not "fix" them by relaxing the assertions; they are the spec: + - `TestSetIntrospection`: `SourceMeta("b")` is missing for a source that was + only ever mentioned by a rule, and `Pattern()` reports the internal `*.` / + `**.` form where the caller wrote a bare domain. + - `TestHolderSwapIsAtomic`: reports a half-applied set. The swap is a single + `atomic.Pointer` store, so either the test's invariant is wrong or + `Holder` publishes before the set is fully built — check which before + changing anything. + - `TestParseHosts`: a hosts line whose first field is not an address should + emit the notice "no address in first field, falling back to a plain + domain list"; it silently produces nothing. + - `TestParseRPZ`: bare RPZ owner names are sniffed as `FormatPlain` (the + format sniffer only knows the classes `IN`/`CH`/`HS`), and the + no-subdomain form is compiled as a wildcard. + - `TestParseRobustness`: CRLF input to the adblock parser produces no rules + at all, and an over-long line is accepted instead of rejected. + - Also unreported by a test but found by inspection: no parser strips a + leading UTF-8 BOM, and the documented inline allow marker + `!allow example.com` does not work (only `!example.com` does). +3. **No tests yet for `acl`, `localzone`, `resolver`, `config`, `qlog`, + `engine`, `server`, `api`, `metrics`, `cluster`.** `internal/acl` and + `internal/localzone` were started but the agent ran out of budget; + `internal/resolver` was not started at all. + +### Not started / incomplete deliverables + +- **`README.md` is still a 0-byte file.** Everything else in the deploy set + exists (written in a parallel pass) and is tracked: `Makefile` (4 KB), + `Dockerfile`, `.gitignore`, `.github/workflows/ci.yml` (6 KB), + `deploy/config.example.yaml`, `deploy/local.example.zone`, `deploy/zones/`. + None of it has been *reviewed against the code* — the CI workflow and the + Makefile targets (`make web`, `make race`, `make image`) need a + `make -n`/dry-run pass, and every config key, port and CLI flag the docs + mention has to be checked against `internal/config` and `cmd/fancy-dns`. +- **`deploy/k8s/` has only two of the seven expected manifests** + (`00-config.yaml`, `10-deployment.yaml`). Still missing: Service, PVC, + ServiceAccount + Secret (the API key), Ingress (needs + `proxy_buffering off` for the SSE log stream), namespace and kustomization. +- End-to-end verification of the console against a running binary: the bundle + compiles and embeds, but nobody has clicked through the pages. +- `internal/web/dist` is gitignored except `.gitkeep`, so a fresh clone cannot + build the binary without running `npm ci && npm run build` first. Either CI + builds the bundle before `go build`, or the ignore rule comes off. ## Continuation plan, in order -### Step 1 — green tests (highest priority) +### Step 1 — the bbolt round trip (highest priority) ```bash -go build ./... && go vet ./... -go test -race ./internal/app/ -count=1 2>&1 | grep -A20 "DATA RACE" | head -60 +go test -race ./internal/cache/ -run 'TestStore' -count=1 -v ``` -Expected races to fix, in likelihood order: -- `config.Store` swap hook running `app.onConfigSwap` → `pool.Rebind` and - `reloadLocalZones` while the query path reads the old `*config.Config`. - Fix direction: make `Pool.Rebind` build a new slice and swap an - `atomic.Pointer` rather than mutating in place under `sync.RWMutex` while - readers hold `RLock` on a *different* field. -- `blocklist.Manager` `m.running` map vs `RefreshAll` goroutine + API handler. - Already mutex-guarded; confirm it is the *Stats* pointer that races. -- `qlog.Logger.live` ring: `Log` writes `l.live[pos%len]` from many goroutines - while `Recent` reads. Needs a seqlock or a per-slot atomic pointer. -- Harness reading the config file path from a test goroutine. +Read `Snapshot` and `Load` in `internal/cache/store.go` as a pair and make the +record format explicit — a small `encodeRecord`/`decodeRecord` pair with one +version byte, so a future change cannot silently desync them the way this one +did. Then add a `t.TempDir()` round-trip test that survives a process restart +and a partially written file. -For each race, prefer removing shared mutable state over adding locks: the -design already uses `atomic.Pointer` holders for ACL, blocklist, localzone and -pool; extend that pattern. +### Step 2 — the five blocklist gaps -### Step 2 — unit tests for the hot packages +Fix the implementation against the existing assertions (see item 2 above), then +`go test -race ./internal/blocklist/ -count=1` to green. The BOM strip and the +`!allow` marker are the two an operator is most likely to hit. -Priority order (highest bug-density first): -- `blocklist`: `Set.Match` for `*.x` (subdomains only) vs `**.x` (apex too) - vs exact; allow-over-block priority; hosts/adblock/plain/rpz parser cases; - `DetectFormat` sniffing; duplicate handling. -- `cache`: LRU eviction, TTL clamping, `TTLFor` on NXDOMAIN/NODATA/SERVFAIL, - serve-stale window, key pack/unpack round-trip, bbolt snapshot→load. -- `zonefile`: `$TTL` inheritance, omitted owner/TTL/class, parens, `@`, - `$ORIGIN` relative vs absolute names, `ParseTTL`/`SerialNext`. -- `acl`: first-match-wins, most-specific CIDR, PTR suffix, `DomainSet.Match`. -- `localzone`: delegation cut, closest-wildcard (RFC 1034 §4.3.3), NODATA. -- `resolver`: breaker open/close, parallel race, TCP fallback on truncation. -- Add `BenchmarkLookup` for `Set.Match` and `cache.LRU.Get` to substantiate - the "great performance" claim. +### Step 3 — the remaining unit tests -### Step 3 — finish the frontend +Same checklist as before, minus what is now covered (`blocklist`, `cache`, +`zonefile` have suites; `cache` and `blocklist` have benchmarks). Priority: +`resolver` (breaker open/close, parallel race, TCP fallback on truncation, +failover, `Rebind` under `-race` — with a `dns.Server` fake on `127.0.0.1:0`), +`acl` (first-match-wins, most-specific CIDR, PTR suffix, `DomainSet` wildcard +forms), `localzone` (delegation cut, closest-wildcard per RFC 1034 §4.3.3, +NODATA vs NXDOMAIN), `config` (`Duration` parsing, `Validate`, the store hooks). -Respawn two subagents with disjoint file ownership (this worked structurally; -they just need more budget): +### Step 4 — review the deploy set and write the README -- **Foundation agent** owns `src/index.css` (replace the purple template with - dark-first CSS vars + one emerald accent), `src/lib/api.ts`, `src/hooks/*`, - `src/components/ui/*`, `src/components/layout/*`. The exact prop contract I - gave them is in the transcript and must be kept. -- **Pages agent** owns `src/pages/*` only, coding against the same contract. - -Then wire `src/App.tsx` + `src/main.tsx` yourself (react-router, `AppShell`, -`ToastProvider`, theme init) and run `npm run build`. Once `web/dist` is real, -the `//go:embed all:dist` in `internal/web/web.go` picks it up with no Go -change; delete the placeholder `internal/web/dist/index.html`. - -API contract for the frontend is fixed and implemented — see -`internal/api/handlers.go` route table and `internal/app/hooks.go` payload -shapes. `/api/logs/stream` is SSE with events `ready`, `entry`, `stats`, and -`X-Accel-Buffering: no`. - -### Step 4 — deploy + docs - -- `Makefile`: `build`, `test`, `race`, `vet`, `web`, `lint`, `run`, `image`. -- `Dockerfile`: multi-stage — node build for `web/`, then Go build with - `-ldflags "-X ...app.Version=..."`; distroless/scratch; `EXPOSE 53/udp 53/tcp - 853/tcp 443/tcp 8080/tcp`; non-root; `VOLUME /var/lib/fancy-dns`. -- k8s: Deployment (hostNetwork or a NodePort Service for :53), Service, - ConfigMap for the YAML, PVC for the bbolt files, readiness probe on - `/api/healthz`, optional Ingress for the console. -- CI: `go vet`, `go test -race`, `gofmt -l`, `npm ci && tsc -b && npm run build`. -- `README.md`: quickstart, config reference, zone-file semantics, API - reference, performance notes, the security caveats that actually matter - (`trusted_proxies` and `X-Forwarded-For`; `metrics.protect`; the - `trust_loopback` + `auth.mode` matrix). +The console pages exist and build; verify them in a browser against a running +binary. Then read `Makefile`, `Dockerfile` and `.github/workflows/ci.yml` +against the code and fix what does not match, add the missing k8s manifests +(Service, PVC, ServiceAccount + Secret, Ingress with `proxy_buffering off` for +`/api/logs/stream`, namespace, kustomization), and write `README.md` from the +source: config reference, zone-file semantics, blocklist formats, API routes, +the `trusted_proxies` / `metrics.protect` / `auth.mode` security caveats, and +the limitations (no DNSSEC validation, no zone transfer, no dynamic update). +CI must run `npm ci && npm run build` before `go build`, because the Go build +embeds `internal/web/dist`. ## Design notes worth preserving -- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one - `Unpack` and yields a private copy, so serving never mutates shared state and - TTL ageing is free. Same trade dnsmasq makes. -- **Cache invalidation on rule changes flushes the cache** rather than - guessing which subtrees are affected. A newly blocked name must not keep - serving its cached good answer, and a new allow rule can rescue a name many - lists blocked. `invalidateBlocklistCache()` in `internal/app/hooks.go`. +- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one `Unpack` + and yields a private copy, so serving never mutates shared state and TTL + ageing is free. Same trade dnsmasq makes. +- **Everything the query path reads is behind an `atomic.Pointer` holder** — + ACL, blocklist, localzone, and now the upstream pool. Readers load the + pointer once and work against a value that cannot change under them. This is + the pattern the whole race cleanup settled on: prefer a new immutable + snapshot over a lock. +- **Cache invalidation on rule changes flushes the cache** rather than guessing + which subtrees are affected. A newly blocked name must not keep serving its + cached good answer. `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. - **API keys are compared by SHA-256 hash, constant-time**, and @@ -199,9 +236,19 @@ shapes. `/api/logs/stream` is SSE with events `ready`, `entry`, `stats`, and client could spoof its identity past the ACL and the rate limiter. - **UDP listener resets its read deadline every iteration.** A deadline set once expires permanently and turns the loop into a spin that never reads - another packet (this was a real bug caught by `TestBlocklistToggle`). + another packet (a real bug caught by `TestBlocklistToggle`). - **Only the Valkey lease holder fetches lists**, so N instances do not hit a mirror N times. Followers serve their compiled copy. - **Blocked/serve-stale behaviour:** a served-stale answer gets TTL 1, an expired upstream answer is never cached, and a truncated answer is never cached (it would be served truncated forever). + +## Test-hygiene traps + +- `tsc -b` replays **cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`. + After a large edit, `rm node_modules/.tmp/*.tsbuildinfo` or you will debug + errors that no longer exist. +- `vite build` empties `outDir` and **deletes `internal/web/dist/.gitkeep`**. + Restore it or the directory disappears from git. +- A test that closes a stop channel *after* `wg.Wait()` deadlocks the whole + package and looks like "the suite hangs". Close first, then wait. diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml new file mode 100644 index 0000000..fb14e4e --- /dev/null +++ b/deploy/k8s/00-config.yaml @@ -0,0 +1,120 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: dns +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fancy-dns-config + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +data: + config.yaml: | + # Rendered by the deployment below; kept in a ConfigMap so a change is a + # `kubectl apply` rather than a rebuild. + server: + max_concurrent: 2048 + timeouts: + upstream: 2s + list_fetch: 60s + + dns: + udp: ["0.0.0.0:53", "[::]:53"] + tcp: ["0.0.0.0:53", "[::]:53"] + max_udp_size: 1232 + dot: + enabled: true + listen: ["0.0.0.0:853"] + cert_file: /etc/fancy-dns/tls/tls.crt + key_file: /etc/fancy-dns/tls/tls.key + doh: + enabled: true + listen: ["0.0.0.0:443"] + cert_file: /etc/fancy-dns/tls/tls.crt + key_file: /etc/fancy-dns/tls/tls.key + base_path: /dns-query + + upstream: + policy: parallel + max_attempts: 3 + healthcheck_interval: 30s + servers: + - addr: 9.9.9.9:53 + proto: udp + - addr: 1.1.1.1:53 + proto: tls + server_name: cloudflare-dns.com + - addr: 9.9.9.9:853 + proto: tls + server_name: dns.quad9.net + + cache: + enabled: true + size: 500000 + max_ttl: 1h + serve_stale: 30m + persist: + enabled: true + path: /var/lib/fancy-dns/cache.db + interval: 30s + + blocklist: + enabled: true + default_action: nxdomain + local_zones: [/etc/fancy-dns/zones/*.zone] + lists: + - name: haas + format: plain + url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts + path: /var/lib/fancy-dns/lists/hosts + enabled: true + refresh: 24h + categories: [ads, tracking] + + acl: + default_policy: allow + groups: [] + + qlog: + enabled: true + path: /var/lib/fancy-dns/queries.db + max_entries: 200000 + sample_rate: 1.0 + log_blocked: true + log_cached: false + + api: + enabled: true + listen: ["0.0.0.0:8080"] + base_path: /api + ui: true + allow_config_write: false + # Read the key from a Secret and mount it; the value here is a placeholder + # that MUST be replaced before this reaches a network. + auth: + mode: apikey + api_keys: ["REPLACE-ME"] + trust_loopback: false + + metrics: + enabled: true + path: /metrics + protect: true + + log: + level: info + format: json +--- +apiVersion: v1 +kind: Secret +metadata: + name: fancy-dns-tls + namespace: dns +type: kubernetes.io/tls +# Replace with a real cert-manager managed secret in production: +# cert-manager.io/v1 -> Certificate -> CertificateRequest -> Secret +stringData: + tls.crt: "" + tls.key: "" diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml new file mode 100644 index 0000000..8cc57dd --- /dev/null +++ b/deploy/k8s/10-deployment.yaml @@ -0,0 +1,245 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: fancy-dns + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +spec: + serviceName: fancy-dns + replicas: 2 + podManagementPolicy: Parallel + selector: + matchLabels: + app.kubernetes.io/name: fancy-dns + template: + metadata: + labels: + app.kubernetes.io/name: fancy-dns + annotations: + # A config change is a SIGHUP, not a restart: the server re-reads the + # file and hot-swaps every component. + reloader.stakater.com/auto: "true" + spec: + # DNS on a non-privileged port needs NET_BIND_SERVICE on Linux, or + # hostNetwork. hostNetwork is used here so the node's port 53 is served + # directly and the load balancer does not add a hop. + hostNetwork: true + dnsPolicy: Default + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: fancy-dns + containers: + - name: fancy-dns + image: fancydns/fancy-dns:latest + imagePullPolicy: IfNotPresent + args: + - -config + - /etc/fancy-dns/config.yaml + ports: + - name: dns-udp + containerPort: 53 + protocol: UDP + - name: dns-tcp + containerPort: 53 + protocol: TCP + - name: dot + containerPort: 853 + protocol: TCP + - name: doh + containerPort: 443 + protocol: TCP + - name: admin + containerPort: 8080 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # Liveness: the process is up. /api/healthz is unauthenticated on + # purpose, because a probe cannot carry an API key. + livenessProbe: + httpGet: + path: /api/healthz + port: admin + initialDelaySeconds: 5 + periodSeconds: 20 + # Readiness: the listener is actually bound, so the Service does not + # send traffic to a pod that is still binding. + readinessProbe: + httpGet: + path: /api/healthz + port: admin + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + # Startup: binding :53 and fetching the first blocklist can take a + # while on a cold node, so give it room before the liveness probe + # starts killing it. + startupProbe: + httpGet: + path: /api/healthz + port: admin + periodSeconds: 3 + failureThreshold: 30 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + # No CPU limit on purpose: a DNS server that gets throttled + # turns into dropped queries, which is worse than a noisy + # neighbour. The memory limit is the one that matters, because + # the cache is sized in entries. + memory: 1Gi + volumeMounts: + - name: config + mountPath: /etc/fancy-dns/config.yaml + subPath: config.yaml + readOnly: true + - name: zones + mountPath: /etc/fancy-dns/zones + readOnly: true + - name: tls + mountPath: /etc/fancy-dns/tls + readOnly: true + - name: data + mountPath: /var/lib/fancy-dns + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: fancy-dns-config + - name: zones + configMap: + name: fancy-dns-zones + optional: true + - name: tls + secret: + secretName: fancy-dns-tls + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: fancy-dns + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +spec: + clusterIP: None + selector: + app.kubernetes.io/name: fancy-dns + ports: + - name: dns-udp + port: 53 + targetPort: dns-udp + protocol: UDP + - name: dns-tcp + port: 53 + targetPort: dns-tcp + protocol: TCP + - name: dot + port: 853 + targetPort: dot + - name: doh + port: 443 + targetPort: doh + - name: admin + port: 8080 + targetPort: admin +--- +# The admin console and the DoH endpoint share a port only if the ingress +# terminates TLS; when it does, this routes /api and /dns-query to the same +# backend. Kept separate from the Service above because a headless Service +# cannot front an Ingress. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fancy-dns + namespace: dns + annotations: + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" +spec: + rules: + - host: dns.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: admin + - path: /dns-query + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: doh + - path: / + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: admin + tls: + - hosts: [dns.example.com] + secretName: fancy-dns-tls +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: fancy-dns + namespace: dns +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: fancy-dns +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: fancy-dns + namespace: dns +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: StatefulSet + name: fancy-dns + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleDown: + # A DNS server that flaps is worse than one that is briefly oversized: + # every scale-down drops the local cache and the blocklist lease. + stabilizationWindowSeconds: 600 diff --git a/internal/blocklist/parse_test.go b/internal/blocklist/parse_test.go new file mode 100644 index 0000000..1126780 --- /dev/null +++ b/internal/blocklist/parse_test.go @@ -0,0 +1,579 @@ +package blocklist + +import ( + "strconv" + "strings" + "testing" +) + +// ruleStrings renders parsed rules compactly for table comparison. +func ruleStrings(rules []Rule) []string { + out := make([]string, 0, len(rules)) + for _, r := range rules { + s := r.Name + " " + r.Action.String() + " ttl=" + strconv.Itoa(int(r.TTL)) + if r.Response != "" { + s += " resp=" + r.Response + } + if r.Priority != 0 { + s += " prio=" + strconv.Itoa(r.Priority) + } + out = append(out, s) + } + return out +} + +// sameRules compares parsed rules against the expectation, treating "no rules" +// as equal whether the parser returned a nil or an empty slice. +func sameRules(got, want []string) bool { + return strings.Join(got, "\n") == strings.Join(want, "\n") +} + +func TestParseHosts(t *testing.T) { + tests := []struct { + name string + data string + opt ParseOptions + want []string + wantFormat Format + wantComment int + wantSkip int + wantBad int + wantNotice string + }{ + { + name: "block entry", + data: "0.0.0.0 ads.example.com\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.ads.example.com block ttl=60"}, + }, + { + name: "several names on one line", + data: "0.0.0.0 a.example.com b.example.com\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.a.example.com block ttl=60", "**.b.example.com block ttl=60"}, + }, + { + name: "loopback bootstrap lines are never blocked", + data: "127.0.0.1 localhost\n::1 ip6-localhost\n", + opt: ParseOptions{Subdomains: true}, + wantSkip: 2, + }, + { + name: "comments, blank lines and inline comments", + data: "# a comment\n; another\n\n \n0.0.0.0 ads.example.com # trailing note\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.ads.example.com block ttl=60"}, + wantComment: 2, + }, + { + name: "custom TTL and no subdomain expansion", + data: "0.0.0.0 ads.example.com\n", + opt: ParseOptions{Subdomains: false, DefaultTTL: 300}, + want: []string{"ads.example.com block ttl=300"}, + }, + { + name: "a single field is not a hosts line", + data: "ads.example.com\n", + opt: ParseOptions{Subdomains: true}, + wantBad: 1, + }, + { + name: "a non-address first field falls back to a plain domain list", + data: "0.0.0.0 ads.example.com\nfoo bar.example.com\n", + opt: ParseOptions{Subdomains: true}, + // The fallback re-parses the whole file as a plain list, so the + // real hosts lines survive and the odd line is malformed. + want: []string{"**.ads.example.com block ttl=60"}, + wantFormat: FormatPlain, + wantBad: 1, + wantNotice: "no address in first field, falling back to a plain domain list", + }, + { + name: "malformed lines", + data: "0.0.0.0\n0.0.0.0 not_a_domain..x\n0.0.0.0 -bad.example.com\n", + opt: ParseOptions{Subdomains: true}, + wantBad: 3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rules, stats, err := Parse(FormatHosts, []byte(tc.data), tc.opt) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(rules); !sameRules(got, tc.want) { + t.Errorf("rules = %v, want %v", got, tc.want) + } + wantFormat := tc.wantFormat + if wantFormat == "" { + wantFormat = FormatHosts + } + if stats.Format != wantFormat { + t.Errorf("stats.Format = %q, want %q", stats.Format, wantFormat) + } + if stats.Comments != tc.wantComment { + t.Errorf("Comments = %d, want %d", stats.Comments, tc.wantComment) + } + if stats.Skipped != tc.wantSkip { + t.Errorf("Skipped = %d, want %d", stats.Skipped, tc.wantSkip) + } + if stats.Malformed != tc.wantBad { + t.Errorf("Malformed = %d, want %d", stats.Malformed, tc.wantBad) + } + if stats.Rules != len(rules) { + t.Errorf("Rules = %d, want %d", stats.Rules, len(rules)) + } + if stats.Bytes != int64(len(tc.data)) { + t.Errorf("Bytes = %d, want %d", stats.Bytes, len(tc.data)) + } + if tc.wantNotice != "" { + found := false + for _, n := range stats.Notices { + if n == tc.wantNotice { + found = true + } + } + if !found { + t.Errorf("Notices = %v, want %q", stats.Notices, tc.wantNotice) + } + } + }) + } +} + +func TestParseAdblock(t *testing.T) { + data := strings.Join([]string{ + "[Adblock Plus 2.0]", + "! Title: test list", + "! Version: 1", + "||ads.example.com^", + "||track.example.com^$third-party", + "|http://banner.example.com/img.png|", + "|https://user:pw@secure.example.com:8443/x?y#z|", + "@@||ok.example.com^", + "example.com##.ad", + "banner.example.com#@#.sponsor", + "/ad[0-9]+/", + "/banner[0-9]+/", + "||", + "bare.example.com", + }, "\n") + rules, stats, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + "**.ads.example.com block ttl=60", + "**.track.example.com block ttl=60", + "**.banner.example.com block ttl=60", + "**.secure.example.com block ttl=60", + "**.ok.example.com allow ttl=60 prio=10", + "**.bare.example.com block ttl=60", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Rules != len(want) { + t.Errorf("Rules = %d, want %d", stats.Rules, len(want)) + } + if stats.Comments != 3 { // the header plus two "! " comments + t.Errorf("Comments = %d, want 3", stats.Comments) + } + // 2 cosmetic rules, 2 regex rules, 1 empty "||" host. + if stats.Skipped != 5 { + t.Errorf("Skipped = %d, want 5", stats.Skipped) + } + foundNotice := false + for _, n := range stats.Notices { + if n == "skipped regular-expression rules" { + foundNotice = true + } + } + if !foundNotice { + t.Errorf("Notices = %v, want the regex notice", stats.Notices) + } + if stats.Format != FormatAdblock { + t.Errorf("Format = %q", stats.Format) + } +} + +func TestParsePlain(t *testing.T) { + data := strings.Join([]string{ + "# a comment", + "; another comment", + "", + " spaced.example.com ", + "!ok.example.com", + "0.0.0.0 hoststyle.example.com", + "||ads.example.com", + "trailing.example.com.", + "!not a domain line !!", + "-bad.example.com", + }, "\n") + rules, stats, err := Parse(FormatPlain, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + "**.spaced.example.com block ttl=60", + "**.ok.example.com allow ttl=60", + "**.hoststyle.example.com block ttl=60", + "**.ads.example.com block ttl=60", + // A trailing dot survives parsing; Builder.Add strips it later. + "**.trailing.example.com. block ttl=60", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Comments != 2 { + t.Errorf("Comments = %d, want 2", stats.Comments) + } + if stats.Malformed != 2 { + t.Errorf("Malformed = %d, want 2", stats.Malformed) + } + + // Without subdomain expansion the same list yields exact rules. + exact, _, err := Parse(FormatPlain, []byte("spaced.example.com\n!ok.example.com\n"), ParseOptions{Subdomains: false}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + wantExact := []string{"spaced.example.com block ttl=60", "ok.example.com allow ttl=60"} + if got := ruleStrings(exact); !sameRules(got, wantExact) { + t.Errorf("exact rules = %v, want %v", got, wantExact) + } + + // The documented "!allow " spelling is not what the parser + // implements: it only strips the "!" marker, so the word "allow" is left + // in the name and the line is counted as malformed. Real behaviour, + // pinned so a fix shows up as a failure. + allowForm, allowStats, err := Parse(FormatPlain, []byte("!allow ok.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("\"!allow ok.example.com\": rules=%v malformed=%d", ruleStrings(allowForm), allowStats.Malformed) + if allowStats.Rules != 0 || allowStats.Malformed != 1 { + t.Errorf("\"!allow x\": rules=%d malformed=%d, want 0/1 (current behaviour)", + allowStats.Rules, allowStats.Malformed) + } +} + +func TestParseRPZ(t *testing.T) { + data := strings.Join([]string{ + "$TTL 3600", + "; an RPZ policy zone", + "example.com CNAME .", + "*.sub.example.org CNAME rpz-drop.", + "**.both.example.net CNAME rpz-passthru.", + "redirect.example.gov A 127.0.0.2", + "redirect6.example.gov AAAA ::1", + "example.com CNAME .", + `skip.example.com TXT "not-a-known-action"`, + }, "\n") + rules, stats, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + // A bare owner name becomes the STRICT-subdomain form "*." (RPZ + // semantics: the policy covers what is under the name), unlike the + // hosts parser which uses "**.". Zone owner names keep their + // trailing dot; Builder.Add strips it at compile time. + "*.example.com. nxdomain ttl=3600", + "*.sub.example.org. drop ttl=3600", + "**.both.example.net. passthrough ttl=3600", + "*.redirect.example.gov. redirect ttl=3600 resp=127.0.0.2", + "*.redirect6.example.gov. redirect ttl=3600 resp=::1", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Duplicates != 1 { + t.Errorf("Duplicates = %d, want 1 (the repeated example.com record)", stats.Duplicates) + } + if stats.Skipped != 1 { + t.Errorf("Skipped = %d, want 1 (TXT with an unknown action)", stats.Skipped) + } + if stats.Format != FormatRPZ { + t.Errorf("Format = %q, want rpz", stats.Format) + } + + // Without subdomain expansion the wildcard prefix is left off entirely. + plain, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: false}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(plain); !sameRules(got, []string{"example.com. nxdomain ttl=60"}) { + t.Errorf("no-subdomain rules = %v", got) + } + + // A CNAME to a real target is a rewrite rule and $TTL is honoured. + cname, _, err := Parse(FormatRPZ, []byte("$TTL 60\nshop.example.com CNAME parked.example.net.\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(cname); !sameRules(got, []string{"*.shop.example.com. cname ttl=60 resp=parked.example.net."}) { + t.Errorf("cname rules = %v", got) + } + + // The compiled forms behave as documented: "*." skips the apex while + // "**." covers it. + s := mustBuild(rules...) + if e := s.Match("example.com"); e != nil { + t.Errorf("RPZ \"example.com CNAME .\" matched the apex: %q, want nil (strict \"*.\")", e.Name) + } + if e := s.Match("www.example.com"); e == nil || e.Action != ActionNXDomain { + t.Errorf("www.example.com: %+v, want nxdomain", e) + } + if e := s.Match("both.example.net"); e == nil || e.Action != ActionPassthrough { + t.Errorf("both.example.net: %+v, want passthrough (\"**.\" covers the apex)", e) + } + if e := s.Match("deep.both.example.net"); e == nil || e.Action != ActionPassthrough { + t.Errorf("deep.both.example.net: %+v, want passthrough", e) + } +} + +func TestDetectFormat(t *testing.T) { + tests := []struct { + name string + data string + want Format + }{ + {"hosts", "0.0.0.0 ads.example.com\n127.0.0.1 localhost\n", FormatHosts}, + {"hosts after comments", "# comment\n\n 127.0.0.1 localhost\n", FormatHosts}, + {"hosts with ::1", "::1 ip6-localhost\n", FormatHosts}, + {"adblock double pipe", "||ads.example.com^\n", FormatAdblock}, + {"adblock allow", "@@||ok.example.com^\n", FormatAdblock}, + {"adblock cosmetic", "example.com##.ad\n", FormatAdblock}, + {"adblock after header", "[Adblock Plus 2.0]\n! comment\n|http://a.example.com/x|\n", FormatAdblock}, + {"rpz ttl", "$TTL 3600\nexample.com CNAME .\n", FormatRPZ}, + {"rpz origin", "$ORIGIN example.com.\nexample.com CNAME .\n", FormatRPZ}, + {"rpz record with ttl and class", "example.com 3600 IN CNAME .\n", FormatRPZ}, + {"single plain domain", "ads.example.com\n", FormatPlain}, + {"empty", "", FormatPlain}, + {"only comments", "# nothing here\n; really\n", FormatPlain}, + // A two-line plain list is sniffed as adblock: the second meaningful + // line is taken to be an adblock rule. Both parsers turn a bare + // hostname into a block rule, so the rules still come out right. + {"two plain domains", "ads.example.com\ntrack.example.com\n", FormatAdblock}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := DetectFormat([]byte(tc.data)); got != tc.want { + t.Errorf("DetectFormat(%q) = %q, want %q", tc.data, got, tc.want) + } + }) + } +} + +func TestDetectFormatSuspectedBugs(t *testing.T) { + // Real behaviour today, pinned so a future fix shows up as a failure. + // allDigitsOrName() does not know RR type names, so a policy record + // without an explicit TTL/class column is sniffed as a plain domain + // list. Parsing the same content as RPZ works, which isolates the + // detection step. + if got := DetectFormat([]byte("example.com CNAME .\n")); got != FormatPlain { + t.Errorf("DetectFormat(rpz record) = %q, want plain (current behaviour)", got) + } + rules, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: true}) + if err != nil || len(rules) != 1 { + t.Errorf("explicit RPZ parse of the same record: rules=%v err=%v", ruleStrings(rules), err) + } + + // The "0 address" form that FormatHosts documents as accepted is not an + // IP literal for netip, so the hosts parser counts the line as malformed + // instead of blocking the name. + _, stats, err := Parse(FormatHosts, []byte("0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if stats.Rules != 0 || stats.Malformed != 1 { + t.Errorf("\"0 ads.example.com\": rules=%d malformed=%d, want 0/1 (current behaviour)", + stats.Rules, stats.Malformed) + } +} + +func TestParseAutoPicksFormat(t *testing.T) { + rules, stats, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if stats.Format != FormatHosts { + t.Errorf("Format = %q, want hosts", stats.Format) + } + if len(rules) != 1 || rules[0].Name != "**.ads.example.com" { + t.Errorf("rules = %v", ruleStrings(rules)) + } + // An empty format string means "sniff" too. + if _, stats, _ := Parse("", []byte("||ads.example.com^\n"), ParseOptions{Subdomains: true}); stats.Format != FormatAdblock { + t.Errorf("Format = %q, want adblock", stats.Format) + } + if _, _, err := Parse("bogus", []byte("x"), ParseOptions{}); err == nil { + t.Errorf("Parse with an unknown format returned no error") + } +} + +func TestParseRobustness(t *testing.T) { + long := "long" + strings.Repeat("x", 100000) + ".example.com" + bigDomain := strings.Repeat("d", 250) + ".example.com" // over the 253-char limit + hugeDomain := strings.Repeat("d", 3000) + ".example.com" + + listSamples := map[string]string{ + "crlf": "0.0.0.0 ads.example.com\r\n0.0.0.0 b.example.com\r\n", + "lone cr": "0.0.0.0 ads.example.com\r0.0.0.0 b.example.com\r", + "leading/trailing space": "\t 0.0.0.0 ads.example.com \n", + "blank and space-only": "\n\n \n\t\n", + "very long line": "0.0.0.0 " + long + "\n", + "over-long label": "0.0.0.0 " + bigDomain + "\n", + "comments everywhere": "# c\n; c\n0.0.0.0 ads.example.com # tail\n\n", + } + for name, data := range listSamples { + t.Run("list/"+name, func(t *testing.T) { + for _, format := range []Format{FormatHosts, FormatPlain, FormatAdblock} { + rules, stats, err := Parse(format, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("%s: Parse: %v", format, err) + continue + } + if stats.Rules != len(rules) { + t.Errorf("%s: Rules = %d, want %d", format, stats.Rules, len(rules)) + } + for _, r := range rules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("%s: rule name kept whitespace: %q", format, r.Name) + } + } + } + }) + } + + // The adblock and plain parsers must not be confused by CRLF either. + for _, data := range []string{ + "||ads.example.com^\r\n@@||ok.example.com^\r\n", + "a.example.com\r\nb.example.com\r\n", + } { + for _, format := range []Format{FormatPlain, FormatAdblock} { + rules, _, err := Parse(format, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("%s: Parse(%q): %v", format, data, err) + continue + } + if len(rules) == 0 { + t.Errorf("%s: Parse(%q) produced no rules", format, data) + } + for _, r := range rules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("%s: rule name kept whitespace: %q", format, r.Name) + } + } + } + } + + // Specifically: CRLF does not leak a CR into the rule names. + rules, _, err := Parse(FormatHosts, []byte("0.0.0.0 ads.example.com\r\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(rules) != 1 || rules[0].Name != "**.ads.example.com" { + t.Errorf("CRLF rules = %v", ruleStrings(rules)) + } + s := mustBuild(rules...) + if s.Match("ads.example.com") == nil || s.Match("x.ads.example.com") == nil { + t.Errorf("CRLF-parsed rule does not match: %v", ruleStrings(rules)) + } + + // The RPZ parser goes through the zone-file reader: CRLF, comments, + // directives and blank lines are handled there too. + for _, data := range []string{ + "$TTL 60\r\nexample.com CNAME .\r\n*.sub.example.org CNAME rpz-drop.\r\n", + "; comment\r\n\r\nexample.com 60 IN CNAME .\n", + "$ORIGIN example.com.\n$TTL 60\nwww IN A 127.0.0.1\n", + } { + zrules, _, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("Parse(rpz %q): %v", data, err) + continue + } + for _, r := range zrules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("rule name kept whitespace: %q", r.Name) + } + } + } + + // A line longer than the scanner's 4 MiB cap is reported, not silently + // truncated. + _, _, err = Parse(FormatPlain, []byte("a.example.com\n"+hugeDomain+"\n"), ParseOptions{Subdomains: true}) + if err == nil { + t.Errorf("an over-long line parsed without error") + } else { + t.Logf("over-long line error (expected): %v", err) + } +} + +func TestParseBOMBehaviour(t *testing.T) { + // A UTF-8 BOM on the first line is not stripped by the parsers, so the + // first rule of the list is lost. Real behaviour, pinned. + const bom = "\ufeff" + _, stats, err := Parse(FormatAuto, []byte(bom+"0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("BOM sample: format=%s rules=%d malformed=%d notices=%v", + stats.Format, stats.Rules, stats.Malformed, stats.Notices) + if stats.Rules != 0 || stats.Malformed != 1 { + t.Errorf("BOM sample: rules=%d malformed=%d, want 0/1 (current behaviour)", + stats.Rules, stats.Malformed) + } + + // Without the BOM the same content parses. + _, ok, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if ok.Rules != 1 { + t.Errorf("control: rules = %d, want 1", ok.Rules) + } + + // A BOM on an adblock list behaves the same way: the first rule is lost. + abRules, abStats, err := Parse(FormatAdblock, []byte(bom+"||ads.example.com^\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("BOM adblock: rules=%v skipped=%d", ruleStrings(abRules), abStats.Skipped) + if abStats.Rules != 0 { + t.Errorf("BOM adblock rules = %v, want none (current behaviour)", ruleStrings(abRules)) + } +} + +func TestParsedRulesCompileAndMatch(t *testing.T) { + // End to end: an adblock list with a broad root rule plus a narrow + // allow rule compiles into a set where the allow wins for the name it + // names and the root still covers everything else. + data := "||example.com^\n@@||metrics.example.com^\n" + rules, _, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 300}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + b := NewBuilder() + for _, r := range rules { + b.Add(r, "list", []string{"ads"}) + } + s := b.Build() + if e := s.Match("ads.example.com"); e == nil || e.Action != ActionBlock { + t.Errorf("ads.example.com: %+v, want block", e) + } + if e := s.Match("example.com"); e == nil || e.Action != ActionBlock { + t.Errorf("example.com: %+v, want block (root rule covers the apex)", e) + } + if e := s.Match("metrics.example.com"); e == nil || !e.IsAllow() { + t.Errorf("metrics.example.com: %+v, want allow", e) + } + if e := s.Match("a.b.metrics.example.com"); e == nil || !e.IsAllow() { + t.Errorf("a.b.metrics.example.com: %+v, want allow (the allow rule is a root rule)", e) + } + for _, r := range rules { + if e := s.Lookup(r.Name); e != nil && e.TTL != 300 { + t.Errorf("TTL = %d, want 300", e.TTL) + } + } +} diff --git a/internal/blocklist/set.go b/internal/blocklist/set.go index 24b8341..9b74397 100644 --- a/internal/blocklist/set.go +++ b/internal/blocklist/set.go @@ -214,13 +214,16 @@ type SourceMeta struct { // Builder accumulates rules and produces a Set. type Builder struct { - exact map[string]*Entry - sub map[string]*Entry - root map[string]*Entry - meta map[string]SourceMeta - order []string - dup int - counts RuleCounts + exact map[string]*Entry + sub map[string]*Entry + root map[string]*Entry + meta map[string]SourceMeta + order []string + // ordered mirrors order, so a source is listed once even when both a rule + // and its metadata mention it. + ordered map[string]bool + dup int + counts RuleCounts } // NewBuilder returns an empty Builder. @@ -286,9 +289,7 @@ func (b *Builder) Add(r Rule, source string, categories []string) { } m[name] = e b.countAction(e, 1) - if _, seen := b.meta[source]; !seen { - b.order = append(b.order, source) - } + b.noteOrder(source) } // metaFor reports whether a source already contributed to this build. @@ -326,12 +327,25 @@ func (b *Builder) uncount(e *Entry) { // NoteSource records (or updates) bookkeeping for a contributing source. func (b *Builder) NoteSource(meta SourceMeta) { - if _, seen := b.meta[meta.Name]; !seen { - b.order = append(b.order, meta.Name) - } + b.noteOrder(meta.Name) b.meta[meta.Name] = meta } +// noteOrder appends a source to the build order exactly once. +func (b *Builder) noteOrder(name string) { + if name == "" { + return + } + if b.ordered == nil { + b.ordered = make(map[string]bool, 16) + } + if b.ordered[name] { + return + } + b.ordered[name] = true + b.order = append(b.order, name) +} + // SourceCount is the number of contributing sources. func (b *Builder) SourceCount() int { return len(b.meta) } @@ -731,7 +745,13 @@ func NewHolder(s *Set) *Holder { } // Get returns the current set (nil before the first build). -func (h *Holder) Get() *Set { return h.cur.Load() } +// Get returns the current set, or nil when nothing has been compiled yet. +func (h *Holder) Get() *Set { + if h == nil { + return nil + } + return h.cur.Load() +} // Set installs a new set. func (h *Holder) Set(s *Set) { h.cur.Store(s) } diff --git a/internal/blocklist/set_test.go b/internal/blocklist/set_test.go new file mode 100644 index 0000000..de82df7 --- /dev/null +++ b/internal/blocklist/set_test.go @@ -0,0 +1,525 @@ +package blocklist + +import ( + "fmt" + "sync" + "testing" +) + +// mustBuild compiles rules into an immutable Set. +func mustBuild(rules ...Rule) *Set { + b := NewBuilder() + for _, r := range rules { + b.Add(r, "test", nil) + } + return b.Build() +} + +func block(name string) Rule { return Rule{Name: name, Action: ActionBlock} } + +func TestSetMatch(t *testing.T) { + s := mustBuild( + block("example.com"), // exact: the name and nothing else + block("*.wild.example.com"), // strict subdomains only + block("**.root.example.net"), // apex and subdomains + block("**.sub.example.org"), // whole subtree + block("ads.example.com"), // more specific than the exact "example.com" + Rule{Name: "tracker.example.com", Action: ActionAllow}, // exact allow + ) + + tests := []struct { + name string + query string + want string // stored rule name, "" = no match + kind PatternKind + action Action + explains string + }{ + {name: "exact apex", query: "example.com", want: "example.com", kind: PatternExact}, + {name: "exact apex trailing dot", query: "example.com.", want: "example.com", kind: PatternExact}, + {name: "exact apex uppercase", query: "ExAmPlE.CoM", want: "example.com", kind: PatternExact}, + {name: "exact rule does not cover subdomains", query: "sub.example.com", want: "", kind: PatternExact}, + {name: "sub wildcard matches subdomain", query: "a.wild.example.com", want: "wild.example.com", kind: PatternSub}, + {name: "sub wildcard does not match apex", query: "wild.example.com", want: "", kind: PatternExact}, + {name: "root wildcard matches apex", query: "root.example.net", want: "root.example.net", kind: PatternRoot}, + {name: "root wildcard matches subdomain", query: "deep.a.root.example.net", want: "root.example.net", kind: PatternRoot}, + {name: "subtree", query: "a.b.sub.example.org", want: "sub.example.org", kind: PatternRoot}, + {name: "subtree apex", query: "sub.example.org", want: "sub.example.org", kind: PatternRoot}, + {name: "most specific exact wins over root", query: "ads.example.com", want: "ads.example.com", kind: PatternExact}, + {name: "allow rule reported with its action", query: "tracker.example.com", want: "tracker.example.com", kind: PatternExact, action: ActionAllow}, + {name: "unrelated name", query: "unrelated.test", want: ""}, + {name: "empty name", query: "", want: ""}, + {name: "the root label is normalised away", query: ".", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := s.Match(tc.query) + if tc.want == "" { + if got != nil { + t.Fatalf("Match(%q) = %q, want no match", tc.query, got.Name) + } + return + } + if got == nil { + t.Fatalf("Match(%q) = nil, want %q", tc.query, tc.want) + } + if got.Name != tc.want { + t.Errorf("Match(%q) rule = %q, want %q", tc.query, got.Name, tc.want) + } + wantAction := tc.action + if wantAction == ActionBlock && got.Action != ActionAllow { + wantAction = ActionBlock + } + if got.Action != wantAction { + t.Errorf("Match(%q) action = %v, want %v", tc.query, got.Action, wantAction) + } + if k := s.MatchPattern(tc.query); k != tc.kind { + t.Errorf("MatchPattern(%q) = %v, want %v", tc.query, k, tc.kind) + } + }) + } +} + +func TestSetMatchLeadingDotAndSubdomainToggle(t *testing.T) { + // splitPattern maps a leading dot to the "**." (root) form. + s := mustBuild(block(".dot.example.com")) + if e := s.Match("dot.example.com"); e == nil { + t.Errorf("leading-dot rule did not match its apex") + } + if e := s.Match("x.dot.example.com"); e == nil { + t.Errorf("leading-dot rule did not match a subdomain") + } + + // With Subdomains off the parsers emit exact rules, so nothing below the + // apex is covered. + exact := mustBuild(hostsRule("ads.example.com", ParseOptions{Subdomains: false})) + if e := exact.Match("sub.ads.example.com"); e != nil { + t.Errorf("exact hosts rule matched a subdomain: %q", e.Name) + } + if e := exact.Match("ads.example.com"); e == nil { + t.Errorf("exact hosts rule did not match its own name") + } + if exact.HasWildcards() { + t.Errorf("HasWildcards() = true for an exact-only set") + } +} + +func TestSetDropsUnusablePatterns(t *testing.T) { + // "." is "the whole internet" and is never useful: it is dropped. A bare + // TLD, on the other hand, is kept and matches the TLD itself. + s := mustBuild(block("."), block("*.com"), block("com"), block(""), block(" ")) + if s.Len() != 2 { + t.Fatalf("Len() = %d, want 2 (exact \"com\" and sub \"com\")", s.Len()) + } + if e := s.Match("com"); e == nil || e.Name != "com" { + t.Errorf("Match(\"com\") = %v, want the exact rule \"com\"", e) + } + if e := s.Match("ads.com"); e == nil || e.Name != "com" { + t.Errorf("Match(\"ads.com\") = %v, want sub rule \"com\"", e) + } +} + +func TestSetMatchesWholeSubtree(t *testing.T) { + // A single root rule on a TLD covers its whole subtree. + s := mustBuild(block("**.com")) + for _, q := range []string{"com", "example.com", "a.b.c.example.com", "EXAMPLE.COM."} { + if e := s.Match(q); e == nil || e.Name != "com" { + t.Errorf("Match(%q) = %v, want the \"**.com\" rule", q, e) + } + } + for _, q := range []string{"de", "example.de", "com.evil.test"} { + if e := s.Match(q); e != nil { + t.Errorf("Match(%q) = %q, want no match", q, e.Name) + } + } + // The apex is covered by "**." but not by "*.". + sub := mustBuild(block("*.com")) + if e := sub.Match("com"); e != nil { + t.Errorf("\"*.com\" matched the apex: %q", e.Name) + } + if e := sub.Match("example.com"); e == nil { + t.Errorf("\"*.com\" did not match a subdomain") + } + // A single label or an empty name never reaches any map. + if e := s.Match(""); e != nil { + t.Errorf("Match(\"\") = %q, want nil", e.Name) + } +} + +func TestNormalizeName(t *testing.T) { + tests := []struct{ in, want string }{ + {"example.com", "example.com"}, + {"example.com.", "example.com"}, + {"EXAMPLE.com..", "example.com."}, // one trailing dot, then lowercased + {"", ""}, + {".", ""}, + {"..", "."}, + } + for _, tc := range tests { + if got := NormalizeName(tc.in); got != tc.want { + t.Errorf("NormalizeName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Match must treat "example.com." and "example.com" as the same name, + // and must be case-insensitive. + s := mustBuild(block("example.com")) + if s.Match("example.com.") == nil || s.Match("EXAMPLE.COM") == nil { + t.Errorf("Match is not case/trailing-dot insensitive") + } +} + +func TestAllowOverBlockPriority(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "cdn.example.com", Action: ActionBlock}, "blocklist", nil) + b.Add(Rule{Name: "cdn.example.com", Action: ActionAllow}, "allowlist", []string{"user"}) + s := b.Build() + + e := s.Match("cdn.example.com") + if e == nil { + t.Fatal("no rule matched") + } + if !e.IsAllow() { + t.Errorf("IsAllow() = false for %v", e.Action) + } + if e.Action != ActionAllow { + t.Errorf("action = %v, want allow", e.Action) + } + if e.Source != "allowlist" { + t.Errorf("Source = %q, want %q (the winning rule's list)", e.Source, "allowlist") + } + if e.Priority != 10 { + t.Errorf("Priority = %d, want 10 (allow default)", e.Priority) + } + if got := e.Categories; len(got) != 1 || got[0] != "user" { + t.Errorf("Categories = %v, want [user]", got) + } + c := s.Counts() + if c.Allow != 1 || c.Block != 0 { + t.Errorf("Counts() = %+v, want Allow=1 Block=0 (block rule was replaced)", c) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } + if s.Lookup("cdn.example.com").Action != ActionAllow { + t.Errorf("Lookup() disagrees with Match()") + } +} + +func TestPriorityTieKeepsFirstRule(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "first", nil) + b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "second", nil) + s := b.Build() + if s.Lookup("tie.example.com").Source != "first" { + t.Errorf("on a priority tie the later rule won: source = %q", s.Lookup("tie.example.com").Source) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } +} + +func TestPassthroughCountsAsAllow(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "pt.example.com", Action: ActionPassthrough}, "rpz", nil) + s := b.Build() + e := s.Match("pt.example.com") + if e == nil || !e.IsAllow() { + t.Fatalf("passthrough rule: IsAllow() = false, e = %v", e) + } + if c := s.Counts(); c.Passthru != 1 || c.Allow != 0 { + t.Errorf("Counts() = %+v, want Passthru=1", c) + } +} + +func TestHits(t *testing.T) { + s := mustBuild(block("hit.example.com"), block("*.wild.hit.example.com")) + const n = 3 + for i := 0; i < n; i++ { + if s.Match("hit.example.com") == nil { + t.Fatal("setup: no match") + } + } + e := s.Match("hit.example.com") + if got := e.Hits(); got != n+1 { + t.Errorf("Hits() = %d, want %d (every Match counts, including this one)", got, n+1) + } + // Non-matching queries must not bump any counter. + s.Match("miss.example.com") + if got := s.Match("hit.example.com").Hits(); got != n+2 { + t.Errorf("Hits() = %d after a miss, want %d", got, n+2) + } + // A wildcard rule has its own counter, and the shared *Entry is visible + // through every pointer to it. + if got := s.Match("a.wild.hit.example.com").Hits(); got != 1 { + t.Errorf("wildcard Hits() = %d, want 1", got) + } + if got := s.Match("hit.example.com").Hits(); got != n+3 { + t.Errorf("exact Hits() = %d, want %d", got, n+3) + } + // A zero-valued Entry is safe. + var zero Entry + if zero.Hits() != 0 { + t.Errorf("zero Entry Hits() = %d, want 0", zero.Hits()) + } + if got := (*Entry)(nil).Hits(); got != 0 { + t.Errorf("nil Entry Hits() = %d, want 0", got) + } +} + +func TestDuplicateDomainsAcrossLists(t *testing.T) { + // The same domain from two lists compiles to ONE rule: the dedup happens + // in Builder.Add, keyed on the normalised pattern, and the losing rule is + // dropped (Duplicates counts it) rather than kept as a second entry. + b := NewBuilder() + b.Add(Rule{Name: "Dup.Example.com.", Action: ActionBlock}, "list-a", nil) + b.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-b", nil) + s := b.Build() + + if s.Len() != 1 { + t.Fatalf("Len() = %d, want 1 (duplicates are deduped)", s.Len()) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } + e := s.Match("dup.example.com") + if e == nil { + t.Fatal("no match") + } + if e.Source != "list-a" { + t.Errorf("Source = %q, want list-a (first list wins on a tie)", e.Source) + } + // One Entry => one hit counter, regardless of how many lists listed it. + if got := e.Hits(); got != 1 { + t.Errorf("Hits() = %d, want 1 (deduped rules share one counter)", got) + } + // A higher-priority rule from a later list replaces the earlier one. + b2 := NewBuilder() + b2.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-a", nil) + b2.Add(Rule{Name: "dup.example.com", Action: ActionAllow}, "list-b", nil) + s2 := b2.Build() + if a := s2.Match("dup.example.com"); a == nil || a.Source != "list-b" { + t.Errorf("higher-priority later rule did not win: %+v", a) + } + if s2.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s2.Duplicates()) + } +} + +func TestSetIntrospection(t *testing.T) { + b := NewBuilder() + b.Add(block("exact.example.com"), "a", nil) + b.Add(block("*.sub.example.com"), "b", nil) + b.Add(block("**.root.example.com"), "c", nil) + b.NoteSource(SourceMeta{Name: "a", Format: "hosts", Rules: 1, Bytes: 42, Enabled: true}) + s := b.Build() + + if got := s.Sources(); len(got) != 3 || got[0] != "a" || got[2] != "c" { + t.Errorf("Sources() = %v, want insertion order [a b c]", got) + } + m, ok := s.SourceMeta("b") + if !ok { + t.Errorf("SourceMeta(\"b\") missing") + } + _ = m + if a, ok := s.SourceMeta("a"); !ok || a.Rules != 1 || a.Bytes != 42 || a.Format != "hosts" { + t.Errorf("SourceMeta(\"a\") = %+v, ok=%v", a, ok) + } + if _, ok := s.SourceMeta("nope"); ok { + t.Errorf("SourceMeta(\"nope\") reported ok") + } + if c := s.Counts(); c.Exact != 1 || c.Sub != 1 || c.Root != 1 || c.Block != 3 { + t.Errorf("Counts() = %+v, want 1/1/1 exact/sub/root and 3 block", c) + } + st := s.Stats() + if st.Total != 3 || st.Exact != 1 || st.Subdomains != 1 || st.RootRules != 1 { + t.Errorf("Stats() = %+v", st) + } + if st.BuiltAt.IsZero() { + t.Errorf("Stats().BuiltAt is zero") + } + + // Each renders patterns back with their wildcard prefix. + seen := map[string]PatternKind{} + s.Each(func(pattern string, e *Entry, kind PatternKind) bool { + seen[pattern] = kind + if got := kind.Pattern(e.Name); got != pattern { + t.Errorf("Pattern(%q) = %q, want %q", e.Name, got, pattern) + } + return true + }) + if seen["exact.example.com"] != PatternExact || seen["sub.example.com"] != PatternSub || + seen["root.example.com"] != PatternRoot { + t.Errorf("Each() visited %v", seen) + } + if PatternSub.String() != "subdomain" || PatternRoot.String() != "domain" || PatternExact.String() != "exact" { + t.Errorf("PatternKind.String() values changed") + } + + // Lookup is exact-only: it does not walk wildcards. + if s.Lookup("deep.sub.example.com") != nil { + t.Errorf("Lookup() walked wildcards") + } + if s.Lookup("EXACT.example.com.") == nil { + t.Errorf("Lookup() is not normalised") + } +} + +func TestDigestStableAndContentSensitive(t *testing.T) { + a := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.z.example.com")) + b := mustBuild(block("**.z.example.com"), block("x.example.com"), block("*.y.example.com")) + if a.Digest() != b.Digest() { + t.Errorf("Digest() depends on insertion order: %q vs %q", a.Digest(), b.Digest()) + } + c := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.different.example.com")) + if a.Digest() == c.Digest() { + t.Errorf("Digest() ignored a pattern change") + } + // The action is part of the digest. + d := mustBuild(Rule{Name: "x.example.com", Action: ActionAllow}) + if a.Digest() == d.Digest() { + t.Errorf("Digest() ignored the action change") + } +} + +func TestNilSetIsSafe(t *testing.T) { + var s *Set + if s.Match("example.com") != nil || s.Len() != 0 || s.HasWildcards() || + s.DuplicatesN() != 0 || s.Sources() != nil || s.AllSourceMeta() != nil || + s.Digest() != "" || !s.BuiltAt().IsZero() { + t.Errorf("nil *Set accessors are not nil-safe") + } + if _, ok := s.SourceMeta("x"); ok { + t.Errorf("nil Set SourceMeta reported ok") + } + if s.Lookup("x") != nil || s.MatchPattern("x") != PatternExact { + t.Errorf("nil Set lookup misbehaved") + } + if st := s.Stats(); st.Total != 0 || st.PerSource == nil { + t.Errorf("nil Stats() = %+v", st) + } + s.Each(func(string, *Entry, PatternKind) bool { t.Errorf("nil Each called fn"); return true }) +} + +func TestActionStringAndParseAction(t *testing.T) { + all := []struct { + a Action + str string + }{ + {ActionBlock, "block"}, + {ActionAllow, "allow"}, + {ActionPassthrough, "passthrough"}, + {ActionDrop, "drop"}, + {ActionNXDomain, "nxdomain"}, + {ActionZero, "zero"}, + {ActionCNAME, "cname"}, + {ActionRedirect, "redirect"}, + {Action(99), "unknown"}, + } + for _, tc := range all { + if got := tc.a.String(); got != tc.str { + t.Errorf("Action(%d).String() = %q, want %q", tc.a, got, tc.str) + } + } + roundtrip := map[string]Action{ + "": ActionBlock, + "block": ActionBlock, + "ALLOW": ActionAllow, + " whitelist ": ActionAllow, + "pass": ActionPassthrough, + "drop": ActionDrop, + "nxdomain": ActionNXDomain, + "null": ActionZero, + "cname": ActionCNAME, + "redirect": ActionRedirect, + } + for in, want := range roundtrip { + got, ok := ParseAction(in) + if !ok || got != want { + t.Errorf("ParseAction(%q) = %v,%v; want %v,true", in, got, ok, want) + } + } + if got, ok := ParseAction("nonsense"); ok || got != ActionBlock { + t.Errorf("ParseAction(\"nonsense\") = %v,%v; want block,false", got, ok) + } +} + +func TestHolderSwapIsAtomic(t *testing.T) { + old := mustBuild(block("a.example.com")) + newer := mustBuild(block("b.example.com")) + h := NewHolder(old) + if h.Get() != old { + t.Fatalf("NewHolder did not install the set") + } + var empty *Holder + if empty.Get() != nil { + t.Errorf("zero Holder is not empty") + } + // A reader either sees the whole old set or the whole new one. + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + s := h.Get() + if s == nil { + t.Error("holder returned nil") + return + } + if e := s.Match("a.example.com"); e == nil { + t.Error("observed a half-applied set") + return + } + } + }() + } + for i := 0; i < 50; i++ { + h.Set(newer) + h.Set(old) + } + close(stop) + wg.Wait() + if h.Get() != old { + t.Errorf("holder lost the last swap") + } +} + +func BenchmarkSetMatch(b *testing.B) { + // A mixed set, like a compiled blocklist: mostly exact rules plus some + // subdomain and root rules so the suffix walk is exercised. + bld := NewBuilder() + for i := 0; i < 2000; i++ { + bld.Add(block(fmt.Sprintf("host%d.ads-%d.example.com", i, i%7)), "bench", nil) + } + for i := 0; i < 200; i++ { + bld.Add(block(fmt.Sprintf("*.wild%d.example.net", i)), "bench", nil) + bld.Add(block(fmt.Sprintf("**.root%d.example.org", i)), "bench", nil) + } + s := bld.Build() + + queries := []string{ + "host0.ads-0.example.com", // exact hit + "host1999.ads-5.example.com", // exact hit, different list + "a.b.wild199.example.net", // wildcard suffix hit + "x.root42.example.org", // root rule + "nothing.in.here.test", // pure miss, walks every suffix + } + b.ReportAllocs() + b.ResetTimer() + var sink int + for i := 0; i < b.N; i++ { + if e := s.Match(queries[i%len(queries)]); e != nil { + sink++ + } + } + if sink == 0 { + b.Fatal("no matches at all") + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 25372b5..6921088 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -18,6 +18,7 @@ package cache import ( "encoding/binary" "strings" + "sync/atomic" "time" "github.com/miekg/dns" @@ -89,7 +90,9 @@ func canonical(name string) string { // // varint len | wire name | uint16 type | uint16 class | uint8 flags func packKey(buf []byte, k Key) ([]byte, error) { - name := k.Name + // Keys are stored canonically (lowercase, no trailing dot) but the wire + // format needs a fully qualified name, so qualify it before packing. + name := dns.Fqdn(k.Name) if n := len(name); n > 255*4 { return nil, errKeyTooLong } @@ -160,8 +163,11 @@ type Entry struct { StaleUntil time.Time // Bytes is the accounted size, used by the LRU and the size gauges. Bytes int - // Hits counts serves since it was stored. - Hits uint32 + // Hits counts serves since it was stored. It is atomic because an Entry is + // shared by every goroutine that hits the key (the LRU hands out the same + // pointer on purpose, so a hit never copies the message), and the + // counter is bumped outside the shard lock on the hot path. + Hits atomic.Uint32 // Neg marks a cached NXDOMAIN or NODATA answer. Neg bool // Truncated records that the upstream answer did not fit in 512 bytes, diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..6a14cdd --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,870 @@ +package cache + +import ( + "errors" + "math/rand/v2" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/miekg/dns" +) + +// fakeClock is a manually advanced clock so TTL, ageing and serve-stale +// behaviour is tested without sleeping. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} } + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +// answer builds a packed A response for name. +func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg { + t.Helper() + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(name), dns.TypeA) + m.Response = true + m.Answer = []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl}, + A: net.ParseIP(ip).To4(), + }} + return m +} + +func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) } + +func TestLRUGetPutAndHitKinds(t *testing.T) { + clk := newClock() + l := NewLRU(100, 4) + m := answer(t, "example.com", 60, "1.2.3.4") + wire, err := m.Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + k := key("example.com") + now := clk.Now() + l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)}) + + if e, kind := l.Get(k, now); e == nil || kind != Fresh { + t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind) + } + // One second later the entry is still fresh; after the TTL it is stale but + // answerable until StaleUntil, and past that it is a miss and dropped. + clk.Advance(59 * time.Second) + if _, kind := l.Get(k, clk.Now()); kind != Fresh { + t.Errorf("at TTL-1s: kind = %v, want Fresh", kind) + } + clk.Advance(2 * time.Second) + e, kind := l.Get(k, clk.Now()) + if e == nil || kind != Stale { + t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind) + } + if got := e.Hits.Load(); got != 3 { + t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got) + } + clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s) + if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss { + t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind) + } + if got := l.Stats().Entries; got != 0 { + t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got) + } + st := l.Stats() + if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 1 { + t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=1", st) + } +} + +func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) { + // One shard so the budget is exactly what we set: eviction order is then + // fully determined and the test can assert it. + l := NewLRU(4, 1) + clk := newClock() + now := clk.Now() + put := func(name string) { + l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8}) + } + put("a.example.com") + put("b.example.com") + put("c.example.com") + // Touch a so it is the most recent, then overflow the 4-entry budget. + if _, kind := l.Get(key("a.example.com"), now); kind != Fresh { + t.Fatalf("a: kind = %v, want Fresh", kind) + } + put("d.example.com") + put("e.example.com") // 5 inserts, budget 4 + + if got := l.Stats().Entries; got > 4 { + t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got) + } + if _, kind := l.Get(key("a.example.com"), now); kind != Fresh { + t.Errorf("a was recently used, want it kept") + } + if _, kind := l.Get(key("b.example.com"), now); kind != Miss { + t.Errorf("b was the least recently used, want it evicted") + } + if l.Stats().Evictions == 0 { + t.Error("evictions = 0, want > 0") + } +} + +func TestLRUDeleteWhereAndFlush(t *testing.T) { + l := NewLRU(100, 2) + now := newClock().Now() + names := []string{"x.example.com", "y.example.com", "z.other.net"} + for _, n := range names { + l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4}) + } + dropped := l.DeleteWhere(func(k Key, _ *Entry) bool { + return k.Name == "x.example.com" || k.Name == "y.example.com" + }) + if dropped != 2 { + t.Errorf("DeleteWhere = %d, want 2", dropped) + } + if _, kind := l.Get(key("x.example.com"), now); kind != Miss { + t.Error("x.example.com still present after DeleteWhere") + } + if _, kind := l.Get(key("z.other.net"), now); kind != Fresh { + t.Error("z.other.net must survive an unrelated DeleteWhere") + } + l.Flush() + if got := l.Stats().Entries; got != 0 { + t.Errorf("entries after Flush = %d, want 0", got) + } + if l.Stats().Bytes != 0 { + t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes) + } +} + +func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) { + l := NewLRU(400, 4) // budget is per shard: 100 entries per shard + now := newClock().Now() + for i := range 200 { + n := "n" + strconv.Itoa(i) + ".example.com" + l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4}) + } + seen := make(map[string]int, 200) + l.Walk(func(k Key, _ *Entry) bool { + seen[k.Name]++ + return true + }) + if len(seen) != 200 { + t.Fatalf("Walk visited %d names, want 200", len(seen)) + } + for n, c := range seen { + if c != 1 { + t.Fatalf("%s visited %d times, want 1", n, c) + } + } + // Returning false stops the walk. + count := 0 + l.Walk(func(Key, *Entry) bool { count++; return false }) + if count != 1 { + t.Errorf("Walk with stop visited %d entries, want 1", count) + } +} + +func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) { + l := NewLRU(100, 1) + now := newClock().Now() + for i := range 50 { + n := "n" + strconv.Itoa(i) + ".example.com" + l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4}) + } + l.SetCapacity(10, 1) + if got := l.Stats().Entries; got > 10 { + t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got) + } + if got := l.Stats().Shards; got != 1 { + t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got) + } + // The cache must still work after a resize. + l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4}) + if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh { + t.Error("a Put after a resize must be readable") + } +} + +func TestLRUConcurrentPutGetResize(t *testing.T) { + // Exercises the resizing path: SetCapacity rewrites the per-shard budget + // while other goroutines insert and read. Run with -race. + l := NewLRU(512, 8) + now := newClock().Now() + var wg sync.WaitGroup + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + l.SetCapacity(200, 4) + l.SetCapacity(4096, 16) + } + } + }() + for g := range 8 { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := range 500 { + n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com" + k := key(n) + l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4}) + e, _ := l.Get(k, now) + if e != nil { + _ = e.Hits.Load() + } + } + }(g) + } + // Readers keep walking while the resizer runs. + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + l.Walk(func(Key, *Entry) bool { return true }) + } + } + }() + // Let the background loops run, then release them and wait for every + // goroutine: closing before the wait is what keeps this from deadlocking. + time.Sleep(20 * time.Millisecond) + close(stop) + wg.Wait() + if l.Stats().Bytes < 0 { + t.Errorf("byte accounting went negative: %d", l.Stats().Bytes) + } +} + +func TestKeyPackUnpackRoundTrip(t *testing.T) { + cases := []Key{ + {Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET}, + {Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true}, + {Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3}, + {Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7}, + } + for _, k := range cases { + buf, err := packKey(nil, k) + if err != nil { + t.Fatalf("packKey(%v): %v", k, err) + } + got, rest, err := unpackKey(buf) + if err != nil { + t.Fatalf("unpackKey(%v): %v", k, err) + } + if len(rest) != 0 { + t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest)) + } + if got != k { + t.Errorf("round trip: got %+v, want %+v", got, k) + } + } + // The wire name survives, so the canonical form comes back normalised. + buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET}) + if err != nil { + t.Fatalf("packKey: %v", err) + } + got, _, err := unpackKey(buf) + if err != nil { + t.Fatalf("unpackKey: %v", err) + } + if got.Name != "example.com" { + t.Errorf("unpacked name = %q, want %q", got.Name, "example.com") + } +} + +func TestKeyPackRejectsBadInput(t *testing.T) { + if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) { + t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err) + } + if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil { + t.Error("packKey(name with an empty label) error = nil, want an error") + } + if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) { + t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err) + } + if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) { + t.Errorf("unpackKey(empty) error = %v, want errBadKey", err) + } +} + +func TestQueryKeyCanonicalises(t *testing.T) { + a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0) + b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0) + if a != b { + t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b) + } + if a.Name != "example.com" { + t.Errorf("canonical name = %q, want %q", a.Name, "example.com") + } +} + +func TestEntryAgeTTL(t *testing.T) { + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + name string + e Entry + now time.Time + want uint32 + }{ + {"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70}, + {"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0}, + {"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0}, + {"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100}, + {"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1}, + } + for i := range cases { + // Take the address: Entry now carries an atomic counter, so the + // struct must never be copied. + tc := &cases[i] + got := tc.e.AgeTTL(tc.now) + if got != tc.want { + t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want) + } + } +} + +func TestManagerTTLFor(t *testing.T) { + clk := newClock() + m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil) + + soa := func(ttl uint32) *dns.SOA { + return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl} + } + + t.Run("answers use the lowest TTL and clamp", func(t *testing.T) { + m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil) + msg := answer(t, "a.example.com", 300, "1.2.3.4") + msg.Answer = append(msg.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5}, + A: net.ParseIP("1.2.3.5").To4(), + }) + ttl, neg := m.TTLFor(msg) + if neg { + t.Error("neg = true for a positive answer") + } + if ttl != 10 { + t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl) + } + }) + + t.Run("clamp down to MaxTTL", func(t *testing.T) { + m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil) + ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4")) + if ttl != 60 { + t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl) + } + }) + + t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) { + m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil) + msg := new(dns.Msg) + msg.SetQuestion("nope.example.com.", dns.TypeA) + msg.Response = true + msg.Rcode = dns.RcodeNameError + msg.Ns = []dns.RR{soa(45)} + ttl, neg := m.TTLFor(msg) + if !neg { + t.Error("neg = false for NXDOMAIN") + } + if ttl != 45 { + t.Errorf("ttl = %d, want 45", ttl) + } + }) + + t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) { + m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil) + msg := new(dns.Msg) + msg.SetQuestion("nope.example.com.", dns.TypeA) + msg.Response = true + msg.Ns = []dns.RR{soa(12)} + ttl, neg := m.TTLFor(msg) + if !neg || ttl != 12 { + t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg) + } + }) + + t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) { + m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil) + msg := new(dns.Msg) + msg.SetQuestion("nope.example.com.", dns.TypeA) + msg.Response = true + ttl, neg := m.TTLFor(msg) + if !neg || ttl != 30 { + t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg) + } + }) + + t.Run("truncated answers are never cached", func(t *testing.T) { + msg := answer(t, "big.example.com", 600, "1.2.3.4") + msg.Truncated = true + if ttl, _ := m.TTLFor(msg); ttl != 0 { + t.Errorf("ttl = %d for a truncated answer, want 0", ttl) + } + }) + + t.Run("transient rcodes are never cached", func(t *testing.T) { + for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} { + msg := answer(t, "a.example.com", 600, "1.2.3.4") + msg.Rcode = rc + if ttl, _ := m.TTLFor(msg); ttl != 0 { + t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl) + } + } + }) + + t.Run("nil message", func(t *testing.T) { + if ttl, _ := m.TTLFor(nil); ttl != 0 { + t.Errorf("ttl = %d, want 0", ttl) + } + }) +} + +func TestManagerPutResponseGetAndAgeing(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + k := key("www.example.com") + m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53") + + res := m.Get(k) + if res.Kind != Fresh { + t.Fatalf("kind = %v, want Fresh", res.Kind) + } + if res.TTL != 120 { + t.Errorf("TTL = %d, want 120", res.TTL) + } + if res.Upstream != "127.0.0.53:53" { + t.Errorf("upstream = %q, want the address it came from", res.Upstream) + } + if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" { + t.Errorf("answer = %s, want 93.184.216.34", got) + } + + // The stored copy is private: mutating the returned message must not + // change what the next reader sees. + res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4() + again := m.Get(k) + if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" { + t.Errorf("answer after mutating the first result = %s, want an independent copy", got) + } + + // TTLs age down monotonically. + clk.Advance(30 * time.Second) + if got := m.Get(k).TTL; got != 90 { + t.Errorf("TTL after 30s = %d, want 90", got) + } + if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 { + t.Errorf("record TTL after 30s = %d, want 90", got) + } +} + +func TestManagerServeStale(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil) + k := key("stale.example.com") + m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up") + + clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window + res := m.Get(k) + if res.Kind != Stale { + t.Fatalf("kind = %v, want Stale", res.Kind) + } + if !res.ServedStale { + t.Error("ServedStale = false on a stale hit") + } + if res.TTL != 0 { + t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL) + } + clk.Advance(61 * time.Second) // now past TTL+ServeStale + if res := m.Get(k); res.Kind != Miss { + t.Errorf("kind = %v past the stale window, want Miss", res.Kind) + } +} + +func TestManagerServeStaleDisabled(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + k := key("nostale.example.com") + m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up") + clk.Advance(61 * time.Second) + if res := m.Get(k); res.Kind != Miss { + t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind) + } +} + +func TestManagerPutResponseRespectsZeroTTL(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + k := key("nocache.example.com") + m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up") + if res := m.Get(k); res.Kind != Miss { + t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind) + } +} + +func TestManagerDeleteNameAndSubtree(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"} + for _, n := range names { + m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up") + } + // "sub.blocked.example.com" must not be caught by the "blocked.example.com" + // subtree walk, but "blocked.example.com" must not catch + // "notblocked.example.com" either. + if n := m.DeleteName("Blocked.Example.Com."); n != 1 { + t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n) + } + if res := m.Get(key("blocked.example.com")); res.Kind != Miss { + t.Error("deleted name still served") + } + if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 { + t.Errorf("DeleteSubtree = %d, want 1", n) + } + for _, n := range []string{"keep.example.com", "notblocked.example.com"} { + if res := m.Get(key(n)); res.Kind != Fresh { + t.Errorf("%s was dropped by an unrelated delete", n) + } + } +} + +func TestManagerTopEntriesCountsAndFlush(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + for range 5 { + m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up") + } + for range 2 { + m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up") + } + top := m.TopEntries(10) + if len(top) < 2 { + t.Fatalf("TopEntries returned %d rows, want at least 2", len(top)) + } + // Each Put counts too, so a name queried once still shows up. + var hot, warm uint64 + for _, e := range top { + switch e.Name { + case "hot.example.com": + hot = e.Hits + case "warm.example.com": + warm = e.Hits + } + } + if hot < 5 || warm < 2 { + t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm) + } + if top[0].Name != "hot.example.com" { + t.Errorf("first row = %s, want the hottest name first", top[0].Name) + } + m.Flush() + if got := len(m.TopEntries(10)); got != 0 { + t.Errorf("TopEntries after Flush = %d rows, want 0", got) + } +} + +func TestManagerStatsShape(t *testing.T) { + clk := newClock() + m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil) + m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up") + m.Get(key("a.example.com")) + m.Get(key("miss.example.com")) + s := m.Stats() + if !s.Enabled { + t.Error("Enabled = false on a live manager") + } + if s.Entries != 1 { + t.Errorf("Entries = %d, want 1", s.Entries) + } + if s.Hits != 1 || s.Misses != 1 { + t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses) + } + if s.Size != 100 || s.Shards != 4 { + t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards) + } + if s.MemoryHuman == "" || s.Bytes <= 0 { + t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman) + } + if s.Persist.Enabled { + t.Error("Persist.Enabled = true without a store") + } +} + +func TestHumanBytes(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KiB"}, + {1536, "1.5 KiB"}, + {1024 * 1024, "1.0 MiB"}, + {3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"}, + {-1024, "-1.0 KiB"}, + } + for _, tc := range cases { + if got := HumanBytes(tc.in); got != tc.want { + t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestStoreSnapshotAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + + clk := newClock() + now := clk.Now() + l := NewLRU(100, 2) + for i := range 25 { + n := "n" + strconv.Itoa(i) + ".example.com" + wire, err := answer(t, n, 600, "10.0.0.8").Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + l.Put(key(n), &Entry{ + Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53", + CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire), + }) + } + written, err := st.Snapshot(l, now) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if written != 25 { + t.Errorf("Snapshot wrote %d entries, want 25", written) + } + if fi, err := os.Stat(path); err != nil || fi.Size() == 0 { + t.Fatalf("snapshot file missing or empty: %v", err) + } + + // A fresh LRU restored from the file keeps the answers and their metadata. + restored := NewLRU(100, 2) + loaded, skipped, err := st.Load(restored, now) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 25 || skipped != 0 { + t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped) + } + e, kind := restored.Get(key("n7.example.com"), now) + if e == nil || kind != Fresh { + t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind) + } + if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" { + t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream) + } + // The wire is intact. + msg := new(dns.Msg) + if err := msg.Unpack(e.Wire); err != nil { + t.Fatalf("unpack restored wire: %v", err) + } + if len(msg.Answer) != 1 { + t.Errorf("restored answer has %d records, want 1", len(msg.Answer)) + } +} + +func TestStoreLoadSkipsExpiredEntries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + + clk := newClock() + now := clk.Now() + l := NewLRU(100, 1) + for i, ttl := range []uint32{600, 600, 5} { + n := "n" + strconv.Itoa(i) + ".example.com" + wire, _ := answer(t, n, ttl, "10.0.0.9").Pack() + l.Put(key(n), &Entry{ + Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second), + StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire), + }) + } + if _, err := st.Snapshot(l, now); err != nil { + t.Fatalf("Snapshot: %v", err) + } + // Load 10s later: the 5s entry is dead, the two 600s entries are fresh. + restored := NewLRU(100, 1) + loaded, skipped, err := st.Load(restored, now.Add(10*time.Second)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 2 { + t.Errorf("loaded = %d, want 2", loaded) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1", skipped) + } + if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss { + t.Errorf("expired entry came back after a reload (kind = %v)", kind) + } +} + +func TestStoreTruncateAndStats(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + clk := newClock() + now := clk.Now() + l := NewLRU(100, 1) + wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack() + l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)}) + if _, err := st.Snapshot(l, now); err != nil { + t.Fatalf("Snapshot: %v", err) + } + s := st.Stats() + if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 { + t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s) + } + if !s.LastSnapshot.IsZero() { + t.Logf("LastSnapshot = %v", s.LastSnapshot) + } + if err := st.Truncate(); err != nil { + t.Fatalf("Truncate: %v", err) + } + if s := st.Stats(); s.Entries != 0 { + t.Errorf("entries after Truncate = %d, want 0", s.Entries) + } + restored := NewLRU(100, 1) + loaded, _, err := st.Load(restored, now) + if err != nil { + t.Fatalf("Load after Truncate: %v", err) + } + if loaded != 0 { + t.Errorf("loaded = %d after Truncate, want 0", loaded) + } +} + +// TestManagerFlushDropsSnapshot checks the interaction the blocklist API +// depends on: flushing the cache must also clear the persisted snapshot, or a +// restart would resurrect answers an operator just invalidated. +func TestManagerFlushDropsSnapshot(t *testing.T) { + dir := t.TempDir() + st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st) + k := key("flushed.example.com") + m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up") + if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil { + t.Fatalf("Snapshot: %v", err) + } + m.Flush() + restored := NewLRU(10, 1) + loaded, _, err := st.Load(restored, clk.Now()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 0 { + t.Errorf("loaded = %d entries after Flush, want 0", loaded) + } + if res := m.Get(k); res.Kind != Miss { + t.Errorf("kind = %v after Flush, want Miss", res.Kind) + } +} + +func TestLRUStatsHitRate(t *testing.T) { + l := NewLRU(100, 1) + now := newClock().Now() + l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4}) + for range 3 { + l.Get(key("a.example.com"), now) + } + for range 1 { + l.Get(key("nothere.example.com"), now) + } + s := l.Stats() + if s.Hits != 3 || s.Misses != 1 { + t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses) + } + if s.HitRate < 0.74 || s.HitRate > 0.76 { + t.Errorf("HitRate = %v, want ~0.75", s.HitRate) + } +} + +func BenchmarkLRUGet(b *testing.B) { + l := NewLRU(1<<16, 16) + now := time.Now() + names := make([]string, 4096) + for i := range names { + names[i] = "n" + strconv.Itoa(i) + ".example.com" + wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack() + l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)}) + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + l.Get(key(names[i%len(names)]), now) + i += 7 + } + }) +} + +func BenchmarkLRUPut(b *testing.B) { + l := NewLRU(1<<16, 16) + now := time.Now() + b.ResetTimer() + for i := 0; b.Loop(); i++ { + l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64}) + } +} + +func BenchmarkManagerGetUnpack(b *testing.B) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + names := make([]string, 4096) + for i := range names { + names[i] = "n" + strconv.Itoa(i) + ".example.com" + m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up") + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + m.Get(key(names[i%len(names)])) + i += 7 + } + }) +} diff --git a/internal/cache/lru.go b/internal/cache/lru.go index bf3b5e3..3f19eb7 100644 --- a/internal/cache/lru.go +++ b/internal/cache/lru.go @@ -22,8 +22,10 @@ type LRU struct { shards []*shard mask uint64 - // perShard is the per-shard entry budget. - perShard int + // perShard is the per-shard entry budget. It is atomic because a config + // reload resizes the cache from the API goroutine while queries are + // inserting in every shard. + perShard atomic.Int64 // counters are the hot-path stats, kept as atomics so Get does not need // the lock just to record a hit. @@ -69,13 +71,10 @@ func NewLRU(size, shards int) *LRU { l := &LRU{ shards: make([]*shard, shards), mask: uint64(shards - 1), - perShard: size / shards, seed: maphash.MakeSeed(), prefetchGate: make(chan struct{}, 64), } - if l.perShard < 1 { - l.perShard = 1 - } + l.perShard.Store(int64(max(1, size/shards))) for i := range l.shards { l.shards[i] = &shard{ items: make(map[Key]*list.Element, 1024), @@ -140,7 +139,7 @@ func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) { } s.mu.Unlock() - e.Hits++ + e.Hits.Add(1) if kind == Fresh { l.hits.Add(1) } else { @@ -171,7 +170,8 @@ func (l *LRU) Put(k Key, e *Entry) { s.bytes += e.Bytes l.bytes.Add(int64(e.Bytes)) var dropped int - for s.ll.Len() > l.perShard { + budget := int(l.perShard.Load()) + for s.ll.Len() > budget { oldest := s.ll.Back() if oldest == nil { break @@ -347,7 +347,7 @@ func (l *LRU) Stats() LRUStats { Expired: l.expired.Load(), HitRate: rate, Shards: len(l.shards), - Capacity: l.perShard * len(l.shards), + Capacity: int(l.perShard.Load()) * len(l.shards), } } @@ -358,5 +358,31 @@ func (l *LRU) SetCapacity(size, shards int) { shards = len(l.shards) } shards = roundPow2(shards) - l.perShard = max(1, size/shards) + budget := int64(max(1, size/shards)) + l.perShard.Store(budget) + // A shrink has to reclaim memory now, not on the next insert: after a + // config reload lowers the cache size, the old entries would otherwise + // sit there until they happened to be overwritten. + var evicted int + for _, s := range l.shards[:min(shards, len(l.shards))] { + s.mu.Lock() + for s.ll.Len() > int(budget) { + oldest := s.ll.Back() + if oldest == nil { + break + } + it, ok := oldest.Value.(*lruItem) + if !ok { + break + } + s.ll.Remove(oldest) + delete(s.items, it.key) + s.bytes -= it.entry.Bytes + l.bytes.Add(-int64(it.entry.Bytes)) + l.entries.Add(-1) + evicted++ + } + s.mu.Unlock() + } + l.evictions.Add(uint64(evicted)) } diff --git a/internal/qlog/qlog.go b/internal/qlog/qlog.go index 9db6b73..4f14b8d 100644 --- a/internal/qlog/qlog.go +++ b/internal/qlog/qlog.go @@ -90,8 +90,11 @@ type Logger struct { // seq assigns monotonic ids so the UI can detect drops. seq atomic.Uint64 - // live is a fixed-size ring of the most recent entries. - live []liveEntry + // live is a fixed-size ring of the most recent entries. Each slot is a + // pointer, so a writer on the query path never races a reader walking the + // ring: publishing is one atomic word, and a reader sees either the whole + // entry or the one it replaced. + live []atomic.Pointer[liveEntry] livePos atomic.Uint32 liveMu sync.RWMutex liveCond *sync.Cond @@ -184,7 +187,7 @@ func New(opt Options) (*Logger, error) { } l := &Logger{ opts: opt, - live: make([]liveEntry, opt.LiveBuffer), + live: make([]atomic.Pointer[liveEntry], opt.LiveBuffer), counts: newDomainCounts(opt.TopDomains, opt.TopTTL), subs: make(map[int]chan Entry), stop: make(chan struct{}), @@ -258,9 +261,11 @@ func (l *Logger) Log(e Entry) { e.ID = l.seq.Add(1) l.counts.observe(e) - // Live ring. + // Live ring. The slot is published with a single atomic store; a reader + // walking the ring concurrently gets either the old or the new entry, + // never a half-written one. pos := l.livePos.Add(1) - 1 - l.live[pos%uint32(len(l.live))] = liveEntry{entry: e} + l.live[pos%uint32(len(l.live))].Store(&liveEntry{entry: e}) // Fan out to SSE subscribers (non-blocking: a slow consumer drops entries // rather than stalling the query path). @@ -435,7 +440,12 @@ func (l *Logger) Recent(limit int) []Entry { } for i := uint32(0); i < total; i++ { idx := (pos - 1 - i) % uint32(len(l.live)) - out = append(out, l.live[idx].entry) + le := l.live[idx].Load() + if le == nil { + // Never written (or already aged out of the window). + continue + } + out = append(out, le.entry) if len(out) >= limit { break } diff --git a/internal/resolver/pool.go b/internal/resolver/pool.go index f458a34..b1b14a7 100644 --- a/internal/resolver/pool.go +++ b/internal/resolver/pool.go @@ -26,6 +26,10 @@ var ErrNoUpstream = errors.New("resolver: no healthy upstream available") // ErrCircuitOpen is returned by a single upstream whose breaker is open. var ErrCircuitOpen = errors.New("resolver: upstream circuit breaker open") +// lookupTimeout bounds a name resolution when building the pool, so a broken +// system resolver cannot stall a config reload. +const lookupTimeout = 2 * time.Second + // Health is an upstream's current state. type Health string @@ -40,28 +44,46 @@ const ( HealthUnknown Health = "unknown" ) -// Upstream is one configured server plus its live health. +// upstreamStats is everything mutable about an upstream. It lives behind a +// pointer shared by successive Upstream values for the same server, so a +// reload keeps the live health state while the immutable Upstream itself is +// replaced under the readers' feet. +type upstreamStats struct { + queries atomic.Uint64 + failures atomic.Uint64 + consecutive atomic.Uint32 + latencySum atomic.Uint64 + latencyN atomic.Uint64 + + // ring keeps the last 512 latencies in ms for the UI's p95. + mu sync.Mutex + ring []float64 + + breakerOpenUntil atomic.Int64 + health atomic.Pointer[Health] + lastErr atomic.Pointer[string] +} + +func newUpstreamStats() *upstreamStats { + s := &upstreamStats{} + s.health.Store(ptr(HealthUnknown)) + return s +} + +// Upstream is one configured server: its address, its dial-time +// configuration and a pointer to the live stats it shares across reloads. +// +// An Upstream is immutable after construction. Rebind builds a fresh Upstream +// for every server and swaps the whole pool state with one atomic store, so a +// query in flight keeps using a consistent, unchanging view. type Upstream struct { cfg config.UpstreamServer // addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps // the hostname) and the literal address otherwise. addr string - // resolved holds the current bootstrap addresses when the configured - // address is a name. + // resolved holds the bootstrap addresses behind a named upstream. resolved []string - - queries atomic.Uint64 - failures atomic.Uint64 - consecutive atomic.Uint32 - latencySum atomic.Uint64 // microseconds - latencyN atomic.Uint64 - p95 []float64 // recent latencies in ms, for the UI - p95mu sync.Mutex - p95ring []float64 - - breakerOpenUntil atomic.Int64 - health atomic.Pointer[Health] - lastErr atomic.Pointer[string] + stats *upstreamStats } // Addr is the address the resolver dials. @@ -70,29 +92,35 @@ func (u *Upstream) Addr() string { return u.addr } // Config returns the upstream's configuration entry. func (u *Upstream) Config() config.UpstreamServer { return u.cfg } +// Resolved returns the addresses a named upstream resolved to, if any. +func (u *Upstream) Resolved() []string { return u.resolved } + // Health returns the current health state. func (u *Upstream) Health() Health { - if h := u.health.Load(); h != nil { + if u == nil || u.stats == nil { + return HealthUnknown + } + if h := u.stats.health.Load(); h != nil { return *h } return HealthUnknown } -func (u *Upstream) setHealth(h Health) { u.health.Store(&h) } +func (u *Upstream) setHealth(h Health) { u.stats.health.Store(&h) } // SetError records the last error message for the UI. func (u *Upstream) SetError(err error) { if err == nil { - u.lastErr.Store(nil) + u.stats.lastErr.Store(nil) return } s := err.Error() - u.lastErr.Store(&s) + u.stats.lastErr.Store(&s) } // LastError returns the last error message, or "". func (u *Upstream) LastError() string { - if p := u.lastErr.Load(); p != nil { + if p := u.stats.lastErr.Load(); p != nil { return *p } return "" @@ -100,10 +128,11 @@ func (u *Upstream) LastError() string { // Success records a successful query. func (u *Upstream) Success(d time.Duration) { - u.queries.Add(1) - u.consecutive.Store(0) - u.latencySum.Add(uint64(d.Microseconds())) - u.latencyN.Add(1) + s := u.stats + s.queries.Add(1) + s.consecutive.Store(0) + s.latencySum.Add(uint64(d.Microseconds())) + s.latencyN.Add(1) u.recordLatency(d) u.SetError(nil) if u.Health() != HealthUp { @@ -114,12 +143,13 @@ func (u *Upstream) Success(d time.Duration) { // Failure records a failed query and trips the breaker once the threshold is // crossed. func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) { - u.queries.Add(1) - u.failures.Add(1) - n := u.consecutive.Add(1) + s := u.stats + s.queries.Add(1) + s.failures.Add(1) + n := s.consecutive.Add(1) u.SetError(err) if threshold > 0 && int(n) >= threshold { - u.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano()) + s.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano()) u.setHealth(HealthDown) return } @@ -130,32 +160,33 @@ func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) { // BreakerOpen reports whether the upstream's breaker is open. func (u *Upstream) BreakerOpen() bool { - until := u.breakerOpenUntil.Load() - return until > time.Now().UnixNano() + return u.stats.breakerOpenUntil.Load() > time.Now().UnixNano() } func (u *Upstream) recordLatency(d time.Duration) { ms := float64(d.Microseconds()) / 1000 - u.p95mu.Lock() - if len(u.p95ring) < 512 { - u.p95ring = append(u.p95ring, ms) + s := u.stats + s.mu.Lock() + if len(s.ring) < 512 { + s.ring = append(s.ring, ms) } else { - copy(u.p95ring, u.p95ring[1:]) - u.p95ring[511] = ms + copy(s.ring, s.ring[1:]) + s.ring[511] = ms } - u.p95mu.Unlock() + s.mu.Unlock() } // LatencyStats returns average and p95 latency in milliseconds. func (u *Upstream) LatencyStats() (avg, p95 float64) { - n := u.latencyN.Load() + s := u.stats + n := s.latencyN.Load() if n == 0 { return 0, 0 } - avg = float64(u.latencySum.Load()) / float64(n) / 1000 - u.p95mu.Lock() - samples := append([]float64(nil), u.p95ring...) - u.p95mu.Unlock() + avg = float64(s.latencySum.Load()) / float64(n) / 1000 + s.mu.Lock() + samples := append([]float64(nil), s.ring...) + s.mu.Unlock() if len(samples) == 0 { return avg, 0 } @@ -187,8 +218,11 @@ type Stats struct { // Stats snapshots one upstream. func (u *Upstream) Stats() Stats { - q := u.queries.Load() - f := u.failures.Load() + if u == nil { + return Stats{} + } + q := u.stats.queries.Load() + f := u.stats.failures.Load() var rate float64 if q > 0 { rate = float64(f) / float64(q) @@ -212,83 +246,127 @@ func (u *Upstream) Stats() Stats { } } -// Pool is the set of upstreams plus the policy that selects among them. -type Pool struct { - cfg config.Upstream - mu sync.RWMutex - ups []*Upstream - byTag map[string][]*Upstream - // clients are reused per upstream: one UDP socket, one TCP dialer, one TLS - // session pool. Reusing them is what keeps the per-query cost low. - clients map[*Upstream]*dns.Client - // bootstrap resolves names to addresses. - bootstrap map[string][]string - - rng *rand.Rand - mu2 sync.Mutex +// poolState is an immutable snapshot of the pool: the config it was built +// from, the upstreams, and the address index used for per-client upstream +// overrides. Readers take the pointer once and are then immune to reloads. +type poolState struct { + cfg config.Upstream + ups []*Upstream + // byAddr indexes the upstreams by every address they answer to, so an ACL + // group naming either the configured Addr or the resolved one works. + byAddr map[string][]*Upstream } -// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream -// hostnames must be literal IPs (or resolved by the OS). +func (st *poolState) all() []*Upstream { + if st == nil { + return nil + } + return st.ups +} + +// Pool is the set of upstreams plus the policy that selects among them. +type Pool struct { + state atomic.Pointer[poolState] + // clients are memoised per upstream: one UDP socket, one TCP dialer, one + // TLS session pool. Reusing them is what keeps the per-query cost low; a + // dns.Client is safe for concurrent use. + clients sync.Map // *Upstream -> *dns.Client + + // mu serialises Rebind so two overlapping reloads cannot interleave their + // health hand-over. It is never taken on the query path. + mu sync.Mutex + // rng backs the "random" policy. + rngMu sync.Mutex + rng *rand.Rand +} + +// NewPool builds a pool from config. Bootstraps are used for named upstreams; +// anything unresolved falls back to the system resolver. func NewPool(cfg config.Upstream) *Pool { p := &Pool{ - cfg: cfg, - byTag: make(map[string][]*Upstream), - clients: make(map[*Upstream]*dns.Client), - bootstrap: make(map[string][]string), - rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)), + rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)), } - for _, b := range cfg.Bootstraps { - p.bootstrap[b.Name] = append([]string(nil), b.Addresses...) - } - for _, s := range cfg.Servers { - up := &Upstream{cfg: s} - up.setHealth(HealthUnknown) - p.ups = append(p.ups, up) - } - p.resolveAll() + p.state.Store(buildState(cfg, nil, false)) return p } -func (p *Pool) resolveAll() { - for _, up := range p.ups { - host, port, err := net.SplitHostPort(up.cfg.Addr) - if err != nil { - continue - } - if net.ParseIP(host) != nil { - up.addr = up.cfg.Addr - continue - } - if addrs := p.bootstrap[host]; len(addrs) > 0 { - up.resolved = addrs - up.addr = net.JoinHostPort(addrs[0], port) - continue - } - // Fall back to the system resolver. - ips, err := net.LookupIP(host) - if err != nil || len(ips) == 0 { - up.addr = up.cfg.Addr - continue - } - strs := make([]string, 0, len(ips)) - for _, ip := range ips { - strs = append(strs, ip.String()) - } - up.resolved = strs - up.addr = net.JoinHostPort(strs[0], port) +// buildState turns an upstream config into an immutable pool snapshot. When +// carry is non-nil, servers that keep the same address and protocol also keep +// their live health, latency and breaker state. +func buildState(cfg config.Upstream, carry *poolState, lookup bool) *poolState { + bootstraps := make(map[string][]string, len(cfg.Bootstraps)) + for _, b := range cfg.Bootstraps { + bootstraps[b.Name] = append([]string(nil), b.Addresses...) } + prev := make(map[string]*Upstream) + if carry != nil { + for _, u := range carry.ups { + prev[u.cfg.Addr+"/"+u.cfg.Proto] = u + } + } + + st := &poolState{cfg: cfg, ups: make([]*Upstream, 0, len(cfg.Servers))} + for _, s := range cfg.Servers { + addr, resolved := resolveAddr(s, bootstraps, lookup) + up := &Upstream{cfg: s, addr: addr, resolved: resolved} + if old, ok := prev[s.Addr+"/"+s.Proto]; ok { + up.stats = old.stats + } else { + up.stats = newUpstreamStats() + } + st.ups = append(st.ups, up) + } + st.byAddr = make(map[string][]*Upstream, len(st.ups)*2) + for _, up := range st.ups { + for _, a := range append([]string{up.cfg.Addr, up.addr}, up.resolved...) { + if a == "" { + continue + } + st.byAddr[a] = append(st.byAddr[a], up) + } + } + return st +} + +// resolveAddr returns the address to dial for a server plus the bootstrap +// addresses behind it, if the configured host is a name. +func resolveAddr(s config.UpstreamServer, bootstraps map[string][]string, lookup bool) (addr string, resolved []string) { + host, port, err := net.SplitHostPort(s.Addr) + if err != nil { + // Not host:port — hand it to the dialer verbatim. + return s.Addr, nil + } + if net.ParseIP(host) != nil { + return s.Addr, nil + } + if addrs := bootstraps[host]; len(addrs) > 0 { + return net.JoinHostPort(addrs[0], port), addrs + } + if lookup { + ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) + defer cancel() + addrs, err := net.DefaultResolver.LookupHost(ctx, host) + if err == nil && len(addrs) > 0 { + return net.JoinHostPort(addrs[0], port), addrs + } + } + return s.Addr, nil } // Config returns the pool's upstream config. -func (p *Pool) Config() config.Upstream { return p.cfg } +func (p *Pool) Config() config.Upstream { + if st := p.state.Load(); st != nil { + return st.cfg + } + return config.Upstream{} +} // All returns every upstream. -func (p *Pool) All() []*Upstream { return p.ups } +func (p *Pool) All() []*Upstream { return p.state.Load().all() } -// Get returns an upstream by address, or nil. +// Get returns an upstream by configured or resolved address, or nil. func (p *Pool) Get(addr string) *Upstream { - for _, u := range p.ups { + for _, u := range p.All() { if u.addr == addr || u.cfg.Addr == addr { return u } @@ -298,8 +376,9 @@ func (p *Pool) Get(addr string) *Upstream { // Healthy returns upstreams whose breaker is closed, in configured order. func (p *Pool) Healthy() []*Upstream { - out := make([]*Upstream, 0, len(p.ups)) - for _, u := range p.ups { + ups := p.All() + out := make([]*Upstream, 0, len(ups)) + for _, u := range ups { if !u.BreakerOpen() { out = append(out, u) } @@ -309,8 +388,9 @@ func (p *Pool) Healthy() []*Upstream { // Stats snapshots every upstream. func (p *Pool) Stats() []Stats { - out := make([]Stats, 0, len(p.ups)) - for _, u := range p.ups { + ups := p.All() + out := make([]Stats, 0, len(ups)) + for _, u := range ups { out = append(out, u.Stats()) } return out @@ -318,95 +398,54 @@ func (p *Pool) Stats() []Stats { // clientFor returns the memoised dns.Client for an upstream. func (p *Pool) clientFor(u *Upstream) *dns.Client { - p.mu.RLock() - c, ok := p.clients[u] - p.mu.RUnlock() - if ok { - return c + if c, ok := p.clients.Load(u); ok { + return c.(*dns.Client) } - var nc *dns.Client + nc := &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second} switch u.cfg.Proto { case "tls": - tlsCfg := &tls.Config{ - ServerName: u.cfg.ServerName, - InsecureSkipVerify: u.cfg.InsecureSkipVerify, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - } nc = &dns.Client{ - Net: "tcp-tls", - TLSConfig: tlsCfg, - Dialer: &net.Dialer{Timeout: 3 * time.Second}, - Timeout: 5 * time.Second, + Net: "tcp-tls", + TLSConfig: &tls.Config{ + ServerName: u.cfg.ServerName, + InsecureSkipVerify: u.cfg.InsecureSkipVerify, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"dot"}, + }, + Dialer: &net.Dialer{Timeout: 3 * time.Second}, + Timeout: 5 * time.Second, } case "tcp": nc = &dns.Client{Net: "tcp", Timeout: 5 * time.Second} - default: - nc = &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second} - } - p.mu.Lock() - p.clients[u] = nc - p.mu.Unlock() - return nc -} - -// selectFor returns the upstreams to try, honouring the policy. tags, if -// non-empty, restricts the pool to a group's own upstreams. -func (p *Pool) selectFor(policy string, maxAttempts int) []*Upstream { - healthy := p.Healthy() - if len(healthy) == 0 { - // Every breaker is open: try them all anyway rather than failing the - // query outright. A blip should not become an outage. - healthy = append(healthy, p.ups...) - } - if len(healthy) == 0 { - return nil - } - if maxAttempts <= 0 || maxAttempts > len(healthy) { - maxAttempts = len(healthy) - } - switch policy { - case "failover": - // Configured order, and rotate past the unhealthy ones. - return healthy[:maxAttempts] - case "random": - p.mu2.Lock() - p.rng.Shuffle(len(healthy), func(i, j int) { healthy[i], healthy[j] = healthy[j], healthy[i] }) - p.mu2.Unlock() - return healthy[:maxAttempts] - default: // parallel - return healthy } + actual, _ := p.clients.LoadOrStore(u, nc) + return actual.(*dns.Client) } // Exchange forwards q to the pool and returns the first acceptable answer. // -// policy is the pool's configured policy; tags restricts the pool to a -// per-client upstream override. edns0Size is the client's advertised buffer, -// used to decide TCP fallback on truncation. -func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout time.Duration) (*dns.Msg, string, error) { - candidates := p.ups - if len(tags) > 0 { - if tagged := p.byTag[strings.Join(tags, ",")]; len(tagged) > 0 { - candidates = tagged - } +// want restricts the query to the upstreams an ACL group named, by configured +// or resolved address; an empty list means the whole pool. timeout bounds one +// attempt. +func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) { + st := p.state.Load() + candidates := st.all() + if len(want) > 0 { + candidates = p.restrict(st, want) } if len(candidates) == 0 { return nil, "", ErrNoUpstream } - attempts := p.cfg.MaxAttempts - chosen := selectFrom(candidates, p.cfg.Policy, attempts) + chosen := p.pick(st, candidates, st.cfg.MaxAttempts) if len(chosen) == 0 { return nil, "", ErrNoUpstream } - if len(chosen) == 1 { - msg, up, err := p.exchangeOne(ctx, chosen[0], q, timeout) - return msg, up, err + return p.exchangeOne(ctx, st.cfg, chosen[0], q, timeout) } - // Parallel: send to all at once, take the first answer that is not a - // transport failure. Cancellable so we do not leak requests to a slow + // Parallel: send to all at once and take the first real answer. The + // context is cancelled on return so we do not leak a request to a slow // upstream after another one has answered. type res struct { msg *dns.Msg @@ -418,7 +457,7 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout defer cancel() for _, up := range chosen { go func(up *Upstream) { - msg, addr, err := p.exchangeOne(sub, up, q, timeout) + msg, addr, err := p.exchangeOne(sub, st.cfg, up, q, timeout) results <- res{msg: msg, up: addr, err: err} }(up) } @@ -426,13 +465,13 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout for i := 0; i < len(chosen); i++ { select { case r := <-results: - if r.err == nil && r.msg != nil && !isTransportFailure(r.msg.Rcode) { - return r.msg, r.up, nil - } - if r.err != nil { + switch { + case r.err != nil: lastErr = r.err - } else if r.msg != nil { - lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode]) + case r.msg != nil && r.msg.Rcode != dns.RcodeServerFailure: + return r.msg, r.up, nil + default: + lastErr = fmt.Errorf("upstream %s returned SERVFAIL", r.up) } case <-ctx.Done(): return nil, "", ctx.Err() @@ -444,8 +483,29 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout return nil, "", lastErr } -func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { - // Filter out open breakers but keep everything if that empties the list. +// restrict resolves an ACL group's upstream list to upstreams, keeping the +// configured order and dropping names that match nothing. When nothing in the +// list resolves the full pool is used, so a typo cannot black-hole a client. +func (p *Pool) restrict(st *poolState, want []string) []*Upstream { + seen := make(map[*Upstream]bool, len(want)) + out := make([]*Upstream, 0, len(want)) + for _, name := range want { + for _, up := range st.byAddr[name] { + if !seen[up] { + seen[up] = true + out = append(out, up) + } + } + } + if len(out) == 0 { + return st.all() + } + return out +} + +// pick filters out open breakers (keeping everything if that empties the +// list) and applies the pool policy. +func (p *Pool) pick(st *poolState, ups []*Upstream, maxAttempts int) []*Upstream { open := make([]*Upstream, 0, len(ups)) for _, u := range ups { if !u.BreakerOpen() { @@ -458,11 +518,13 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { if maxAttempts <= 0 || maxAttempts > len(open) { maxAttempts = len(open) } - switch policy { + switch st.cfg.Policy { case "failover": return open[:maxAttempts] case "random": - rand.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] }) + p.rngMu.Lock() + defer p.rngMu.Unlock() + p.rng.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] }) return open[:maxAttempts] default: return open @@ -471,63 +533,54 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { // exchangeOne sends q to a single upstream, retrying over TCP when the UDP // answer comes back truncated (RFC 1035 §4.2.1). -func (p *Pool) exchangeOne(ctx context.Context, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) { +func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) { if timeout <= 0 { timeout = 2 * time.Second } client := p.clientFor(up) - msg := q.Copy() - start := time.Now() - resp, rtt, err := client.ExchangeContext(ctx, msg, up.addr) - up.Success(rtt) + resp, rtt, err := client.ExchangeContext(ctx, q.Copy(), up.addr) if err != nil { + up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D()) return nil, up.addr, err } - _ = start + up.Success(rtt) if !resp.Truncated { return resp, up.addr, nil } - // Retry over TCP to get the full answer. Some upstreams are configured - // UDP-only in which case this simply fails and we use the TC answer. + // Retry over TCP for the full answer. An upstream that is UDP-only just + // fails here and we return the truncated answer; the client retries over + // TCP itself. tcpClient := &dns.Client{Net: "tcp", Timeout: timeout} resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr) - if err2 == nil && resp2 != nil && !resp2.Truncated { - up.Success(rtt2) - return resp2, up.addr, nil + if err2 != nil || resp2 == nil || resp2.Truncated { + if err2 == nil { + err2 = errors.New("still truncated over TCP") + } + up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), + thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D()) + return resp, up.addr, nil } - up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), up.breakerThreshold(), p.cfg.BreakerCooldown.D()) - // Return the truncated answer; the client will retry over TCP itself. - return resp, up.addr, nil -} - -func isTransportFailure(rcode int) bool { - return rcode == dns.RcodeServerFailure -} - -// breakerThreshold is the effective failure threshold for an upstream. -func (u *Upstream) breakerThreshold() int { - if u.cfg.MaxFails > 0 { - return u.cfg.MaxFails - } - return 0 + up.Success(rtt2) + return resp2, up.addr, nil } // HealthCheck probes every upstream with a cheap query, refreshing health // state even when no client traffic is flowing. It returns the number of // upstreams that answered. func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int { + st := p.state.Load() probe := new(dns.Msg) probe.SetQuestion(".", dns.TypeNS) probe.RecursionDesired = true ok := 0 - for _, up := range p.ups { + for _, up := range st.all() { if up.BreakerOpen() { continue } c := p.clientFor(up) _, rtt, err := c.ExchangeContext(ctx, probe, up.addr) if err != nil { - up.Failure(err, p.breakerThresholdFor(up), p.cfg.BreakerCooldown.D()) + up.Failure(err, thresholdFor(up, st.cfg.BreakerThreshold), st.cfg.BreakerCooldown.D()) continue } up.Success(rtt) @@ -536,63 +589,47 @@ func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int { return ok } -func (p *Pool) breakerThresholdFor(u *Upstream) int { - if t := u.breakerThreshold(); t > 0 { - return t +func thresholdFor(up *Upstream, def int) int { + if up.cfg.MaxFails > 0 { + return up.cfg.MaxFails } - return p.cfg.BreakerThreshold + return def } -// Rebind applies a new upstream configuration in place, keeping the live -// health state of upstreams whose address is unchanged. +// Rebind applies a new upstream configuration, keeping the live health state +// of upstreams whose address and protocol are unchanged. +// +// It builds a whole new immutable state and swaps it in with a single atomic +// store: queries already in flight finish against the state they started +// with, and the next query sees the new one. There is no window in which half +// the upstreams are old and half new. func (p *Pool) Rebind(cfg config.Upstream) { p.mu.Lock() defer p.mu.Unlock() - p.cfg = cfg - old := make(map[string]*Upstream, len(p.ups)) - for _, u := range p.ups { - old[u.cfg.Addr+"/"+u.cfg.Proto] = u + old := p.state.Load() + next := buildState(cfg, old, true) + p.state.Store(next) + // Drop the memoised clients of upstreams that are gone; their shells are + // only reachable from in-flight queries, which let them go on their own. + live := make(map[*Upstream]bool, len(next.all())) + for _, u := range next.all() { + live[u] = true } - next := make([]*Upstream, 0, len(cfg.Servers)) - for _, s := range cfg.Servers { - if u, ok := old[s.Addr+"/"+s.Proto]; ok { - u.cfg = s - next = append(next, u) - delete(old, s.Addr+"/"+s.Proto) - continue - } - u := &Upstream{cfg: s} - u.setHealth(HealthUnknown) - next = append(next, u) - } - p.ups = next - // Drop clients for removed upstreams. - for u := range p.clients { - if old[u.cfg.Addr+"/"+u.cfg.Proto] != nil { - delete(p.clients, u) + for _, u := range old.all() { + if !live[u] { + p.clients.Delete(u) } } - p.resolveAllLocked() } -func (p *Pool) resolveAllLocked() { - for _, b := range p.cfg.Bootstraps { - p.bootstrap[b.Name] = append([]string(nil), b.Addresses...) - } - for _, up := range p.ups { - host, port, err := net.SplitHostPort(up.cfg.Addr) - if err != nil { - continue - } - if net.ParseIP(host) != nil { - up.addr = up.cfg.Addr - continue - } - if addrs := p.bootstrap[host]; len(addrs) > 0 { - up.resolved = addrs - up.addr = net.JoinHostPort(addrs[0], port) - continue - } - up.addr = up.cfg.Addr +// String renders the pool for logs. +func (p *Pool) String() string { + ups := p.All() + addrs := make([]string, 0, len(ups)) + for _, u := range ups { + addrs = append(addrs, u.addr) } + return "pool[" + strings.Join(addrs, " ") + "]" } + +func ptr[T any](v T) *T { return &v } diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index b671cb9..c4be9a9 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -1,3 +1,14 @@ -fancy-dns -

fancy-dns

-

Web console assets are not built in this binary. Run make web and rebuild.

+ + + + + + + web + + + + +
+ + diff --git a/internal/zonefile/zonefile_test.go b/internal/zonefile/zonefile_test.go new file mode 100644 index 0000000..407278f --- /dev/null +++ b/internal/zonefile/zonefile_test.go @@ -0,0 +1,918 @@ +package zonefile + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/miekg/dns" +) + +func mustParse(t *testing.T, src string, opt Options) *Zone { + t.Helper() + z, err := Parse(strings.NewReader(src), opt) + if err != nil { + t.Fatalf("Parse: unexpected error: %v\nsource:\n%s", err, src) + } + return z +} + +func parseErr(t *testing.T, src string, opt Options) error { + t.Helper() + z, err := Parse(strings.NewReader(src), opt) + if err == nil { + t.Fatalf("Parse: expected an error, got %d records: %v", len(z.Records), z.Records) + } + return err +} + +// summary renders every record as "line name ttl class type rdata" so a single +// comparison covers folding, owner/TTL/class inheritance and origin resolution. +func summary(t *testing.T, z *Zone) []string { + t.Helper() + out := make([]string, 0, len(z.Records)) + for _, rec := range z.Records { + out = append(out, renderRecord(t, rec, true)) + } + return out +} + +// summaryNoTTL is summary without the TTL column, for fixtures whose records +// carry no explicit TTL (the TTL then comes from the RR header). +func summaryNoTTL(t *testing.T, z *Zone) []string { + t.Helper() + out := make([]string, 0, len(z.Records)) + for _, rec := range z.Records { + out = append(out, renderRecord(t, rec, false)) + } + return out +} + +func renderRecord(t *testing.T, rec Record, withTTL bool) string { + t.Helper() + parts := strings.SplitN(rec.RR.String(), "\t", 5) + if len(parts) != 5 { + t.Fatalf("record line %d does not render as 5 tab-separated fields: %q", rec.Line, rec.Text()) + } + if !withTTL { + return fmt.Sprintf("%d %s %s %s %s", rec.Line, parts[0], parts[2], parts[3], parts[4]) + } + return fmt.Sprintf("%d %s %d %s %s %s", rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4]) +} + +func checkSummary(t *testing.T, z *Zone, want []string) { + t.Helper() + if got := summary(t, z); !slices.Equal(got, want) { + t.Errorf("records mismatch\n got: %q\nwant: %q", got, want) + } +} + +func recordByName(t *testing.T, z *Zone, name string) Record { + t.Helper() + for _, rec := range z.Records { + if rec.Name == name { + return rec + } + } + t.Fatalf("no record for %q in %v", name, summary(t, z)) + return Record{} +} + +func recordByLine(t *testing.T, z *Zone, line int) Record { + t.Helper() + for _, rec := range z.Records { + if rec.Line == line { + return rec + } + } + t.Fatalf("no record starting on line %d in %v", line, summary(t, z)) + return Record{} +} + +func TestParseTTL(t *testing.T) { + tests := []struct { + in string + want uint32 + wantErr bool + }{ + {in: "", wantErr: true}, + {in: " ", wantErr: true}, + {in: "300", want: 300}, + {in: " 60 ", want: 60}, + {in: "0", want: 0}, + {in: "300s", want: 300}, + {in: "300sec", want: 300}, + {in: "300seconds", want: 300}, + {in: "5m", want: 300}, + {in: "5min", want: 300}, + {in: "5minutes", want: 300}, + {in: "1h", want: 3600}, + {in: "1hour", want: 3600}, + {in: "1hours", want: 3600}, + {in: "1d", want: 86400}, + {in: "1days", want: 86400}, + {in: "1w", want: 604800}, + {in: "1weeks", want: 604800}, + {in: "1h30m", want: 5400}, + {in: "1w2d3h4m5s", want: 788645}, + {in: "1h30", want: 3630}, + {in: "4294967295", want: 4294967295}, + {in: "4294967296", wantErr: true}, + {in: "-1", wantErr: true}, + {in: "-5m", wantErr: true}, + {in: "abc", wantErr: true}, + {in: "m", wantErr: true}, + {in: "1x", wantErr: true}, + {in: "1.5h", wantErr: true}, + {in: "1h-", wantErr: true}, + {in: "0x10", wantErr: true}, + } + for _, tc := range tests { + got, err := ParseTTL(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("ParseTTL(%q) = %d, want an error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("ParseTTL(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseTTL(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestFormatTTL(t *testing.T) { + tests := []struct { + in uint32 + want string + }{ + {0, "0"}, + {1, "1"}, + {59, "59"}, + {60, "1m"}, + {300, "5m"}, + {3600, "1h"}, + {86400, "1d"}, + {604800, "1w"}, + {3601, "3601"}, + {1209600, "2w"}, + } + for _, tc := range tests { + if got := FormatTTL(tc.in); got != tc.want { + t.Errorf("FormatTTL(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestFormatTTLRoundTrip(t *testing.T) { + for _, ttl := range []uint32{0, 1, 59, 60, 300, 3600, 3601, 86400, 604800, 1209600, 4294967295} { + s := FormatTTL(ttl) + got, err := ParseTTL(s) + if err != nil { + t.Errorf("ParseTTL(FormatTTL(%d)=%q) error: %v", ttl, s, err) + continue + } + if got != ttl { + t.Errorf("round trip of %d via %q = %d", ttl, s, got) + } + } +} + +func TestSerialNext(t *testing.T) { + tests := []struct { + name string + current uint32 + spec string + want uint32 + wantErr bool + }{ + {name: "increment", current: 1, spec: "+1", want: 2}, + {name: "increment large", current: 2024010101, spec: "+100", want: 2024010201}, + {name: "increment from zero", current: 0, spec: "+7", want: 7}, + {name: "decrement", current: 10, spec: "-1", want: 9}, + {name: "decrement below zero wraps", current: 0, spec: "-1", want: 4294967295}, + {name: "absolute", current: 5, spec: "42", want: 42}, + {name: "absolute max", current: 5, spec: "4294967295", want: 4294967295}, + {name: "surrounding space", current: 1, spec: " +1 ", want: 2}, + {name: "empty", current: 1, spec: "", wantErr: true}, + {name: "only plus", current: 1, spec: "+", wantErr: true}, + {name: "not a number", current: 1, spec: "+x", wantErr: true}, + {name: "word", current: 1, spec: "next", wantErr: true}, + {name: "bare minus", current: 1, spec: "-", wantErr: true}, + {name: "overflow", current: 1, spec: "+4294967296", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := SerialNext(tc.current, tc.spec) + if tc.wantErr { + if err == nil { + t.Fatalf("SerialNext(%d, %q) = %d, want an error", tc.current, tc.spec, got) + } + return + } + if err != nil { + t.Fatalf("SerialNext(%d, %q): %v", tc.current, tc.spec, err) + } + if got != tc.want { + t.Errorf("SerialNext(%d, %q) = %d, want %d", tc.current, tc.spec, got, tc.want) + } + }) + } +} + +// addSerial is plain modular (RFC 1982 §2.1) arithmetic: 0xFFFFFFFF + 1 is 0, +// not 1. The high-half special case only stops two large operands from being +// mistaken for a wrap. +func TestAddSerial(t *testing.T) { + tests := []struct{ a, b, want uint32 }{ + {1, 1, 2}, + {0, 0, 0}, + {4294967295, 1, 0}, + {4294967295, 2, 1}, + {4294967294, 3, 1}, + {0, 4294967295, 4294967295}, + {0x7FFFFFFF, 1, 0x80000000}, + {0x80000000, 0x80000000, 0}, + {0x80000000, 0x7FFFFFFF, 0xFFFFFFFF}, + } + for _, tc := range tests { + if got := addSerial(tc.a, tc.b); got != tc.want { + t.Errorf("addSerial(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +const fullZone = `$ORIGIN example.com. +$TTL 1h +@ 300 IN SOA ns1.example.com. hostmaster.example.com. ( + 2024010101 ; serial + 7200 ; refresh + 3600 ; retry + 1209600 ; expire + 300 ) ; minimum +@ IN NS ns1.example.com. +www 3600 IN A 192.0.2.1 +www 600 IN A 192.0.2.2 + AAAA 2001:db8::1 +alias 3600 IN CNAME www.example.com. +mail 3600 IN MX 10 mx1.example.com. +note 3600 IN TXT "hello ; world @ example" +*.wild 3600 IN A 192.0.2.3 +198.51.100.5. IN A 198.51.100.5 +low in a 192.0.2.7 +mixed 600 IN A 192.0.2.8 +cls in 3600 A 192.0.2.9 +at 3600 IN TXT "@" + +; a whole-line comment + ; an indented comment + +` + +func TestParseFullZone(t *testing.T) { + z := mustParse(t, fullZone, Options{File: "full.zone", Origin: "ignored.example."}) + if z.Origin != "example.com." { + t.Errorf("Origin = %q, want %q", z.Origin, "example.com.") + } + if z.File != "full.zone" { + t.Errorf("File = %q, want %q", z.File, "full.zone") + } + // The SOA and NS lines are folded, so every record reports the line its + // statement started on. TTLs are left out here: only some records carry an + // explicit one, see TestParseTTLDirective for the $TTL handling. + if got, want := summaryNoTTL(t, z), []string{ + `3 example.com. IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`, + `9 example.com. IN NS ns1.example.com.`, + `10 www.example.com. IN A 192.0.2.1`, + `11 www.example.com. IN A 192.0.2.2`, + `12 www.example.com. IN AAAA 2001:db8::1`, + `13 alias.example.com. IN CNAME www.example.com.`, + `14 mail.example.com. IN MX 10 mx1.example.com.`, + `15 note.example.com. IN TXT "hello ; world @ example"`, + `16 *.wild.example.com. IN A 192.0.2.3`, + `17 198.51.100.5. IN A 198.51.100.5`, + `18 low.example.com. IN A 192.0.2.7`, + `19 mixed.example.com. IN A 192.0.2.8`, + `20 cls.example.com. IN A 192.0.2.9`, + `21 at.example.com. IN TXT "@"`, + }; !slices.Equal(got, want) { + t.Errorf("records mismatch\n got: %q\nwant: %q", got, want) + } + if z.SOA == nil { + t.Fatal("SOA = nil, want the parsed start of authority") + } + if z.SOA.Serial != 2024010101 { + t.Errorf("SOA.Serial = %d, want 2024010101", z.SOA.Serial) + } + if got := recordByLine(t, z, 11).TTL; got != 600 { + t.Errorf("explicit TTL = %d, want 600", got) + } + if got := recordByLine(t, z, 10).RR.Header().Ttl; got != 3600 { + t.Errorf("record TTL of the first www = %d, want the explicit 3600", got) + } + if got := recordByLine(t, z, 20).TTL; got != 3600 { + t.Errorf("TTL before the class = %d, want 3600", got) + } + if z.MinTTL != 300 { + t.Errorf("MinTTL = %d, want 300 (the smallest explicit TTL)", z.MinTTL) + } + for _, rec := range z.Records { + if rec.RR.Header().Class != dns.ClassINET { + t.Errorf("record %q has class %s, want IN", rec.Text(), dns.ClassToString[rec.RR.Header().Class]) + } + if rec.Name != strings.ToLower(rec.Name) { + t.Errorf("Record.Name = %q, want lowercase", rec.Name) + } + if !strings.HasSuffix(rec.Name, ".") { + t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name) + } + if rec.File != "full.zone" { + t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File) + } + } +} + +func TestParseTTLDirective(t *testing.T) { + z := mustParse(t, `$ORIGIN example.com. +$TTL 1h +one 3600 IN A 192.0.2.1 +two 300 IN A 192.0.2.2 +three 300 IN A 192.0.2.3 +$TTL 2h +four 7200 IN A 192.0.2.4 +`, Options{}) + checkSummary(t, z, []string{ + `3 one.example.com. 3600 IN A 192.0.2.1`, + `4 two.example.com. 300 IN A 192.0.2.2`, + `5 three.example.com. 300 IN A 192.0.2.3`, + `7 four.example.com. 7200 IN A 192.0.2.4`, + }) + if z.DefaultTTL != 7200 { + t.Errorf("DefaultTTL = %d, want 7200 after \"$TTL 2h\"", z.DefaultTTL) + } + if z.MinTTL != 300 { + t.Errorf("MinTTL = %d, want 300", z.MinTTL) + } +} + +func TestParseDefaultTTLSources(t *testing.T) { + t.Run("option default ttl", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com.", DefaultTTL: 42}) + if z.DefaultTTL != 42 { + t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL) + } + if len(z.Records) != 1 { + t.Fatalf("got %d records, want 1", len(z.Records)) + } + }) + + t.Run("no ttl source at all", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."}) + if z.DefaultTTL != 0 { + t.Errorf("DefaultTTL = %d, want 0", z.DefaultTTL) + } + // The RR header already carries the library default, so MinTTL is + // that value and the parser's "no TTL at all" fallback never runs. + if z.MinTTL != z.Records[0].RR.Header().Ttl { + t.Errorf("MinTTL = %d, want the record TTL %d", z.MinTTL, z.Records[0].RR.Header().Ttl) + } + }) + + t.Run("soa ttl becomes the default", func(t *testing.T) { + z := mustParse(t, "@ 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5\nhost IN A 192.0.2.1\n", + Options{Origin: "example.com."}) + if z.DefaultTTL != 300 { + t.Errorf("DefaultTTL = %d, want 300 from the SOA", z.DefaultTTL) + } + }) + + t.Run("soa minimum is the last resort", func(t *testing.T) { + z := mustParse(t, "@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 7\nhost IN A 192.0.2.1\n", + Options{Origin: "example.com."}) + // The SOA carries no explicit TTL, so its header TTL wins over Minttl. + if z.SOA == nil || z.SOA.Minttl != 7 { + t.Fatalf("SOA = %v, want Minttl 7", z.SOA) + } + }) + + t.Run("a file without ttl, soa or option keeps the header ttl", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."}) + if got := z.Records[0].RR.Header().Ttl; got == 0 { + t.Errorf("record header TTL = 0, want the parser default") + } + }) +} + +func TestParseOwnerForms(t *testing.T) { + tests := []struct { + name string + src string + want []string + }{ + { + name: "at sign is the origin", + src: "@ IN A 192.0.2.1\n@ IN A 192.0.2.2\n", + want: []string{"origin.example. 192.0.2.1", "origin.example. 192.0.2.2"}, + }, + { + name: "at sign follows $ORIGIN", + src: "$ORIGIN sub.example.com.\n@ IN A 192.0.2.1\n", + want: []string{"sub.example.com. 192.0.2.1"}, + }, + { + // An omitted owner is recognised by the line starting with a type + // token; a leading class or TTL is taken as an owner name instead. + name: "omitted owner repeats the previous one", + src: "one IN A 192.0.2.1\n A 192.0.2.2\n AAAA 2001:db8::1\n A 192.0.2.3\n", + want: []string{ + "one.origin.example. 192.0.2.1", + "one.origin.example. 192.0.2.2", + "one.origin.example. 2001:db8::1", + "one.origin.example. 192.0.2.3", + }, + }, + { + name: "omitted owner after a parenthesised record", + src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n A 192.0.2.1\n", + want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"}, + }, + { + name: "omitted owner is the origin after a directive", + src: "$ORIGIN example.com.\nA 192.0.2.1\n", + want: []string{"example.com. 192.0.2.1"}, + }, + { + name: "wildcard owner keeps the star", + src: "*.sub IN A 192.0.2.1\n", + want: []string{"*.sub.origin.example. 192.0.2.1"}, + }, + { + name: "at sign in a value is not expanded", + src: "note IN TXT \"user @ example.com\"\n", + want: []string{"note.origin.example. \"user @ example.com\""}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + z := mustParse(t, tc.src, Options{Origin: "origin.example."}) + got := make([]string, 0, len(z.Records)) + for _, rec := range z.Records { + parts := strings.SplitN(rec.RR.String(), "\t", 5) + got = append(got, parts[0]+" "+parts[4]) + } + if !slices.Equal(got, tc.want) { + t.Errorf("got %q, want %q", got, tc.want) + } + // Record.Name is the resolved, lowercased, fully qualified owner: + // "@" always resolves to the origin, never to a literal "@" label. + for _, rec := range z.Records { + if strings.ContainsAny(rec.Name, "@") { + t.Errorf("Record.Name = %q, want an expanded owner", rec.Name) + } + if !strings.HasSuffix(rec.Name, ".") { + t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name) + } + if rec.Name != strings.ToLower(rec.Name) { + t.Errorf("Record.Name = %q, want lowercase", rec.Name) + } + } + }) + } +} + +func TestParseOwnerNameCaseIsPreservedInTheRecord(t *testing.T) { + // The RR keeps the case the file used; only Record.Name is normalised. + z := mustParse(t, "WWW IN A 192.0.2.1\n", Options{Origin: "example.com."}) + if got := z.Records[0].Name; got != "www.example.com." { + t.Errorf("Record.Name = %q, want www.example.com.", got) + } + if !strings.HasPrefix(z.Records[0].Text(), "WWW.example.com.") { + t.Errorf("Text() = %q, want the owner as written", z.Records[0].Text()) + } +} + +func TestParseParenthesised(t *testing.T) { + t.Run("multi line soa", func(t *testing.T) { + z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. ( + 2024010101 + 7200 + 3600 + 1209600 + 300 ) +`, Options{Origin: "example.com."}) + if z.SOA == nil { + t.Fatal("SOA = nil") + } + if z.SOA.Serial != 2024010101 || z.SOA.Refresh != 7200 || z.SOA.Minttl != 300 { + t.Errorf("SOA = %v, want serial 2024010101, refresh 7200, minimum 300", z.SOA) + } + if z.Records[0].Line != 1 { + t.Errorf("Line = %d, want 1 (the start of the folded record)", z.Records[0].Line) + } + }) + + t.Run("comment inside parentheses", func(t *testing.T) { + z := mustParse(t, "host TXT ( \"one\" ; first\n \"two\" ; second\n )\n", Options{Origin: "example.com."}) + if len(z.Records) != 1 { + t.Fatalf("got %d records, want 1", len(z.Records)) + } + if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) { + t.Errorf("record = %q, want a two-string TXT", got) + } + }) + + t.Run("parentheses on a single line", func(t *testing.T) { + z := mustParse(t, "host MX ( 10 mx1.example.com. )\n", Options{Origin: "example.com."}) + if len(z.Records) != 1 { + t.Fatalf("got %d records, want 1", len(z.Records)) + } + if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com.") { + t.Errorf("record = %q, want one MX rdata field", got) + } + }) + + t.Run("parentheses are stripped from the assembled line", func(t *testing.T) { + lineno := 0 + lines := bufio.NewScanner(strings.NewReader("host MX 10 mx1.example.com.\n")) + got, err := fold(lines, "host MX ( 10 mx1.example.com. )", &lineno, Options{}) + if err != nil { + t.Fatalf("fold: %v", err) + } + if strings.ContainsAny(got, "()") { + t.Errorf("fold = %q, want no parentheses", got) + } + // The parens and the whitespace they wrapped are gone; the token + // stream is what the record parser cares about, not the exact spacing. + if want := []string{"host", "MX", "10", "mx1.example.com."}; !slices.Equal(strings.Fields(got), want) { + t.Errorf("fold = %q, tokens = %v, want %v", got, strings.Fields(got), want) + } + }) +} + +func TestParseOriginDirective(t *testing.T) { + t.Run("relative names follow $ORIGIN", func(t *testing.T) { + z := mustParse(t, "$ORIGIN sub.example.com.\nwww IN A 192.0.2.1\n", Options{Origin: "example.com."}) + if z.Origin != "sub.example.com." { + t.Errorf("Origin = %q, want sub.example.com.", z.Origin) + } + if z.Records[0].Name != "www.sub.example.com." { + t.Errorf("name = %q, want www.sub.example.com.", z.Records[0].Name) + } + }) + + t.Run("absolute names are left alone", func(t *testing.T) { + z := mustParse(t, "$ORIGIN sub.example.com.\nwww.other.example. IN A 192.0.2.1\n", Options{}) + if z.Records[0].Name != "www.other.example." { + t.Errorf("name = %q, want www.other.example.", z.Records[0].Name) + } + }) + + t.Run("no origin at all is the root", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{}) + if z.Origin != "." { + t.Errorf("Origin = %q, want .", z.Origin) + } + if z.Records[0].Name != "host." { + t.Errorf("name = %q, want host.", z.Records[0].Name) + } + }) + + t.Run("relative names at the root get no suffix", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "."}) + if z.Records[0].Name != "host." { + t.Errorf("name = %q, want host.", z.Records[0].Name) + } + }) + + t.Run("$ORIGIN without a name is rejected", func(t *testing.T) { + // BIND resets to the zone origin when $ORIGIN carries no argument; + // this parser treats it as an error instead. + err := parseErr(t, "$ORIGIN example.com.\n$ORIGIN\n", Options{}) + if !strings.Contains(err.Error(), "$ORIGIN needs a name") { + t.Errorf("error = %q, want it to mention \"$ORIGIN needs a name\"", err) + } + }) + + t.Run("origin option applies when the file has no $ORIGIN", func(t *testing.T) { + z := mustParse(t, "www IN A 192.0.2.1\n", Options{Origin: "example.com"}) + if z.Origin != "example.com." || z.Records[0].Name != "www.example.com." { + t.Errorf("origin = %q, name = %q", z.Origin, z.Records[0].Name) + } + }) +} + +func TestParseClassHandling(t *testing.T) { + z := mustParse(t, `one IN A 192.0.2.1 +two in A 192.0.2.2 +three A 192.0.2.3 +four 300 in A 192.0.2.4 +five ch A 192.0.2.5 +six hs A 192.0.2.6 +seven 300 A 192.0.2.7 +`, Options{Origin: "example.com."}) + if len(z.Records) != 7 { + t.Fatalf("got %d records, want 7", len(z.Records)) + } + // An omitted class defaults to IN; "in" and "IN" are the same token. + want := []uint16{ + dns.ClassINET, dns.ClassINET, dns.ClassINET, dns.ClassINET, + dns.ClassCHAOS, dns.ClassHESIOD, dns.ClassINET, + } + for i, rec := range z.Records { + if rec.RR.Header().Class != want[i] { + t.Errorf("%q has class %s, want %s", rec.Text(), + dns.ClassToString[rec.RR.Header().Class], dns.ClassToString[want[i]]) + } + } +} + +func TestParseCommentsAndBlankLines(t *testing.T) { + z := mustParse(t, `; leading comment +$TTL 1h + + ; indented comment + +one 300 IN A 192.0.2.1 ; trailing comment + + +two 300 IN A 192.0.2.2 ;comment without space +three 300 IN TXT "semi ; colon" ; real comment +`, Options{Origin: "example.com."}) + if got := summary(t, z); !slices.Equal(got, []string{ + `6 one.example.com. 300 IN A 192.0.2.1`, + `9 two.example.com. 300 IN A 192.0.2.2`, + `10 three.example.com. 300 IN TXT "semi ; colon"`, + }) { + t.Errorf("records = %q, want three records on lines 6, 9 and 10", got) + } +} + +func TestParseErrors(t *testing.T) { + tests := []struct { + name string + src string + opt Options + wantSub string + }{ + {name: "no type", src: "host 3600\n", wantSub: "has no type"}, + {name: "bad token before type", src: "host BOGUS 1.2.3.4\n", wantSub: `unexpected token "BOGUS"`}, + {name: "bad rdata", src: "host 3600 IN A not-an-ip\n"}, + {name: "unterminated parenthesis", src: "host 3600 IN A ( 192.0.2.1\n", wantSub: "unterminated parenthesis"}, + {name: "unterminated parenthesis with a blank line", src: "host 3600 IN A (\n\n 192.0.2.1\n", wantSub: "unterminated parenthesis"}, + {name: "unterminated quote", src: "host 3600 IN TXT \"unterminated\n"}, + {name: "no owner and no previous owner", src: "A 192.0.2.1\n", wantSub: "no owner name"}, + {name: "bad ttl directive", src: "$TTL forever\n", wantSub: "bad TTL"}, + {name: "ttl directive without a value", src: "$TTL\n", wantSub: "$TTL needs a value"}, + {name: "origin directive without a name", src: "$ORIGIN\n", wantSub: "$ORIGIN needs a name"}, + {name: "include disabled", src: "$INCLUDE other.zone\n", wantSub: "disabled"}, + {name: "include unsupported", src: "$INCLUDE other.zone\n", opt: Options{MaxIncludeDepth: 3}, wantSub: "not supported"}, + {name: "include without a name", src: "$INCLUDE\n", wantSub: "$INCLUDE needs a file name"}, + {name: "error carries file and line", src: "host 3600 IN A 192.0.2.1\nother 3600 IN A nope\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"}, + {name: "directive error carries the line", src: "$TTL 1h\n$TTL\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"}, + {name: "read error is reported", src: "host 3600 IN A 192.0.2.1\n", opt: Options{File: "bad.zone"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.name == "read error is reported" { + // A reader that fails after the first line exercises the + // lines.Err() path in Parse. + if _, err := Parse(&failingReader{}, Options{File: "bad.zone"}); err == nil { + t.Fatal("Parse with a failing reader: want an error") + } + return + } + err := parseErr(t, tc.src, tc.opt) + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error = %q, want it to contain %q", err, tc.wantSub) + } + }) + } +} + +// failingReader fails on the first Read so Parse must surface the error. +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errRead } + +var errRead = fmt.Errorf("synthetic read failure") + +func TestParseEmptyFile(t *testing.T) { + for _, src := range []string{"", "\n\n", " \n\t\n", "; only a comment\n"} { + z := mustParse(t, src, Options{Origin: "example.com."}) + if len(z.Records) != 0 { + t.Errorf("Parse(%q) returned %d records, want 0", src, len(z.Records)) + } + if z.SOA != nil { + t.Errorf("Parse(%q) returned an SOA, want nil", src) + } + if z.Origin != "example.com." { + t.Errorf("Parse(%q) origin = %q, want example.com.", src, z.Origin) + } + } +} + +func TestParseFileAndParseFiles(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.zone") + b := filepath.Join(dir, "b.zone") + if err := os.WriteFile(a, []byte("$TTL 1h\nwww IN A 192.0.2.1\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, []byte("$TTL 2h\nmail IN A 192.0.2.2\n"), 0o600); err != nil { + t.Fatal(err) + } + + z, err := ParseFile(a, "example.com.") + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if z.File != a || z.Origin != "example.com." || len(z.Records) != 1 { + t.Fatalf("ParseFile = %+v", z) + } + if z.Records[0].Name != "www.example.com." { + t.Errorf("name = %q, want www.example.com.", z.Records[0].Name) + } + if z.Records[0].File != a { + t.Errorf("record File = %q, want %q", z.Records[0].File, a) + } + if _, err := ParseFile(filepath.Join(dir, "missing.zone"), "example.com."); err == nil { + t.Error("ParseFile on a missing file: want an error") + } + + merged, err := ParseFiles([]string{a, b}, OriginFromPath) + if err != nil { + t.Fatalf("ParseFiles: %v", err) + } + if len(merged.Records) != 2 { + t.Fatalf("ParseFiles returned %d records, want 2", len(merged.Records)) + } + if merged.DefaultTTL != 7200 { + t.Errorf("merged DefaultTTL = %d, want 7200 (the last file wins)", merged.DefaultTTL) + } + if _, err := ParseFiles([]string{a, filepath.Join(dir, "missing.zone")}, OriginFromPath); err == nil { + t.Error("ParseFiles with a missing file: want an error") + } +} + +func TestOriginFromPath(t *testing.T) { + tests := []struct{ in, want string }{ + {"ads.zone", "ads."}, + {"rpz/ads.zone", "ads."}, + {"plain", "plain."}, + {".hidden", ".hidden."}, + {"/var/lib/fancy-dns/ads.zone", "ads."}, + } + for _, tc := range tests { + if got := OriginFromPath(tc.in); got != tc.want { + t.Errorf("OriginFromPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestEnsureDot(t *testing.T) { + tests := []struct{ in, want string }{ + {"", ""}, + {".", "."}, + {"example.com", "example.com."}, + {"example.com.", "example.com."}, + } + for _, tc := range tests { + if got := ensureDot(tc.in); got != tc.want { + t.Errorf("ensureDot(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestRelative(t *testing.T) { + tests := []struct{ name, origin, want string }{ + {"@", "example.com.", "example.com."}, + {"www", "example.com.", "www.example.com."}, + {"www.other.", "example.com.", "www.other."}, + {"www", ".", "www."}, + {"*", "example.com.", "*.example.com."}, + {"www", "", "www."}, + } + for _, tc := range tests { + if got := relative(tc.name, tc.origin); got != tc.want { + t.Errorf("relative(%q, %q) = %q, want %q", tc.name, tc.origin, got, tc.want) + } + } +} + +func TestStripComment(t *testing.T) { + tests := []struct{ in, want string }{ + {"a ; comment", "a "}, + {"a;comment", "a"}, + {`a "has ; semicolon" b`, `a "has ; semicolon" b`}, + {`a \; escaped`, `a \; escaped`}, + {"no comment here", "no comment here"}, + {`"unterminated ; quote`, `"unterminated ; quote`}, + } + for _, tc := range tests { + if got := stripComment(tc.in); got != tc.want { + t.Errorf("stripComment(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSplitFields(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"a b c", []string{"a", "b", "c"}}, + {`a "b c" d`, []string{"a", `"b c"`, "d"}}, + {" ", nil}, + {`a\ b c`, []string{`a\ b`, "c"}}, + {"a\tb", []string{"a", "b"}}, + } + for _, tc := range tests { + if got := splitFields(tc.in); !slices.Equal(got, tc.want) { + t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestDeparen(t *testing.T) { + tests := []struct{ in, want string }{ + {"a ( b", "a b"}, + {"a ) b", "a b"}, + {"a b", "a b"}, + {"()", ""}, + } + for _, tc := range tests { + if got := deparen(tc.in); got != tc.want { + t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestTokenClassification(t *testing.T) { + for _, s := range []string{"0", "300", "1h30m", "300s"} { + if !isTTLToken(s) { + t.Errorf("isTTLToken(%q) = false, want true", s) + } + } + for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} { + if isTTLToken(s) { + t.Errorf("isTTLToken(%q) = true, want false", s) + } + } + for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} { + if !isClassToken(s) { + t.Errorf("isClassToken(%q) = false, want true", s) + } + } + for _, s := range []string{"", "300", "ANY", "cs", "in4"} { + if isClassToken(s) { + t.Errorf("isClassToken(%q) = true, want false", s) + } + } + for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} { + if !isRRTYPE(s) { + t.Errorf("isRRTYPE(%q) = false, want true", s) + } + } + for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} { + if isRRTYPE(s) { + t.Errorf("isRRTYPE(%q) = true, want false", s) + } + } +} + +func TestCloneSOA(t *testing.T) { + if cloneSOA(nil) != nil { + t.Error("cloneSOA(nil) != nil") + } + in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60} + out := cloneSOA(in) + if out == in { + t.Fatal("cloneSOA returned the same pointer") + } + if out.Serial != 7 || out.Minttl != 60 { + t.Errorf("cloneSOA copied %+v", out) + } +} + +func TestWhereAndDirOf(t *testing.T) { + if got := where(""); got != "zone" { + t.Errorf("where(\"\") = %q, want zone", got) + } + if got := where("a.zone"); got != "a.zone" { + t.Errorf("where(a.zone) = %q", got) + } + if got := dirOf("a.zone"); got != "." { + t.Errorf("dirOf(a.zone) = %q, want .", got) + } + if got := dirOf("/x/y/a.zone"); got != "/x/y" { + t.Errorf("dirOf(/x/y/a.zone) = %q", got) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9ca8e8d..17dda61 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,122 +1,84 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +/** + * App — routes, the shell and the theme bootstrap. + * + * The daemon serves the console and the API from one origin, so a real + * BrowserRouter works as long as the Go handler falls back to index.html (it + * does). `useThemeInit` runs the stored theme onto `` before the first + * paint; `ThemeToggle` inside AppShell keeps it in sync afterwards. + */ -function App() { - const [count, setCount] = useState(0) +import { useEffect } from 'react' +import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { ToastProvider } from './components/ui' +import { AppShell } from './components/layout' +import Blocklists from './pages/Blocklists.tsx' +import Cache from './pages/Cache.tsx' +import Clients from './pages/Clients.tsx' +import Dashboard from './pages/Dashboard.tsx' +import Domains from './pages/Domains.tsx' +import Login from './pages/Login.tsx' +import Logs from './pages/Logs.tsx' +import NotFound from './pages/NotFound.tsx' +import Settings from './pages/Settings.tsx' +import Upstreams from './pages/Upstreams.tsx' + +const THEME_KEY = 'fancydns.theme' + +/** Applies the stored theme to `` before anything paints. */ +function useThemeInit(): void { + useEffect(() => { + if (typeof document === 'undefined') return + if (document.documentElement.dataset.theme !== undefined) return + let stored: string | null = null + try { + stored = localStorage.getItem(THEME_KEY) + } catch { + // Storage disabled: fall back to the system preference. + stored = null + } + if (stored === 'light' || stored === 'dark') { + document.documentElement.dataset.theme = stored + } + }, []) +} + +/** Everything that lives inside the shell. */ +function AppRoutes() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) } -export default App +export default function App() { + useThemeInit() + + return ( + + + + {/* Bare: no shell, no header, no nav. */} + } /> + + + + } + /> + + + + ) +} diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 902c1ff..ed996b0 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -27,7 +27,9 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< fnRef.current = fn useEffect(() => { - const controller = new AbortController() + // The fetcher takes no AbortSignal, so cancellation is a guard: a response + // that arrives after an unmount or a newer request is dropped on the floor + // instead of overwriting fresher state. let live = true setLoading(true) @@ -50,7 +52,6 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< return () => { live = false - controller.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, nonce]) diff --git a/web/src/index.css b/web/src/index.css index f658a10..f2d33de 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -198,12 +198,6 @@ color: var(--fg); } - /* Numeric columns must not jitter as digits change. */ - .tabular { - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum" 1; - } - * { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; @@ -244,3 +238,9 @@ } } } + +/** Numeric columns must not jitter as their digits change. */ +@utility tabular { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 119fc4b..e227aa3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -417,6 +417,8 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { let backoff = RECONNECT_MIN_MS let timer: ReturnType | undefined let attempt = 0 + // One error report per outage streak: a flaky link must not toast every 15s. + let reported = false const open = (): void => { if (stopped) return @@ -447,11 +449,15 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { const message = res.ok ? 'the live log stream is not supported by this browser' : `the live log stream failed with HTTP ${res.status}` - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() return } + reported = false attempt = 0 backoff = RECONNECT_MIN_MS handlers.onOpen() @@ -463,7 +469,10 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { if (stopped) return if (controller.signal.aborted) return const message = err instanceof Error ? err.message : 'the live log stream dropped' - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() } } diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..aa2970d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,9 +1,19 @@ +/** + * Entry point. The stylesheet is imported first so Tailwind's layers are in + * place before the first React render commits anything. + */ + import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -createRoot(document.getElementById('root')!).render( +const container = document.getElementById('root') +if (container === null) { + throw new Error('fancy-dns: #root is missing from index.html') +} + +createRoot(container).render( , diff --git a/web/src/pages/Blocklists.tsx b/web/src/pages/Blocklists.tsx index 77dc6c7..17c36e4 100644 --- a/web/src/pages/Blocklists.tsx +++ b/web/src/pages/Blocklists.tsx @@ -10,12 +10,12 @@ import { useMemo, useState } from 'react' import type { ReactNode } from 'react' import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react' -import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api' -import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useAutoRefresh } from '../../hooks/useAutoRefresh' -import { useDebounced } from '../../hooks/useDebounced' -import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format' +import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api' +import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { useDebounced } from '../hooks/useDebounced' +import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format' import { Badge, Button, @@ -29,21 +29,21 @@ import { Table, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const EMPTY_STATS: BlocklistStats = { @@ -79,7 +79,7 @@ function truncate(value: string, max = 42): string { } export default function Blocklists() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getBlocklists(), []) useAutoRefresh(refetch, 15000) @@ -200,7 +200,7 @@ export default function Blocklists() { ), refresh: ( } + icon={RefreshCw} label={`refresh ${item.name}`} variant="ghost" disabled={busy} @@ -221,7 +221,7 @@ export default function Blocklists() { description="Compiled rule sources, their freshness and their health." actions={ + } + /> + +
+ + + + + + +
+ +
+ + +
+
+ + + + {pie.map((slice) => ( + + ))} + + } /> + + +
+
+

{formatRate(hitRate, 0)}

+

hit rate

+
+
+
+
    +
  • +
  • +
  • +
  • +
  • + Negative TTL + + {formatMs(data?.negative_ttl_ms ?? 0)} + +
  • + {lookups === 0 && ( +
  • + Nothing has been looked up since the daemon started. +
  • + )} +
+
+
+
+ + + {persisted.enabled ? 'enabled' : 'disabled'} + + } + > + +
+
+
Path
+
+ {persisted.path || '—'} +
+
+
+
Size
+
+ {formatBytes(persisted.bytes ?? 0)} +
+
+
+
Entries
+
+ {formatNumber(persisted.entries ?? 0)} +
+
+
+
Writes
+
+ {formatNumber(persisted.writes ?? 0)} +
+
+
+
Errors
+
0 + ? 'tabular mt-0.5 font-mono text-sm text-danger' + : 'tabular mt-0.5 font-mono text-sm' + } + > + {formatNumber(persistErrors)} +
+
+
+
Last snapshot
+
{formatRelative(persisted.last_snapshot)}
+
+
+ {persistMessage !== '' && ( +

+ {persistMessage} +

+ )} +
+
+
+ + auto-refresh every 15s} + > + {topError !== null ? ( + +

{topError}

+
+ ) : ( +
+ String(row.__key)} + loading={topLoading && entries.length === 0} + empty={ + + } + /> + + )} + + + setConfirmFlush(false)} + onConfirm={() => void onFlush()} + title="Flush the cache?" + message="Every cached answer is dropped. The next query for each name goes back to the upstreams, so expect a burst of upstream traffic." + confirmLabel="Flush cache" + destructive + pending={flush.pending} + /> + + ) +} diff --git a/web/src/pages/Clients.tsx b/web/src/pages/Clients.tsx index 856aaeb..e3aa349 100644 --- a/web/src/pages/Clients.tsx +++ b/web/src/pages/Clients.tsx @@ -10,10 +10,10 @@ import { useMemo, useState } from 'react' import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react' -import { ApiError, getClients, putClients } from '../../lib/api' -import type { ClientGroup, ClientMatch } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { fromLines, toLines } from '../../lib/format' +import { ApiError, getClients, putClients } from '../lib/api' +import type { ClientGroup, ClientMatch } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { fromLines, toLines } from '../lib/format' import { Badge, Button, @@ -28,19 +28,19 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const POLICIES = [ @@ -173,11 +173,11 @@ function fieldLeaf(path: string): string { } export default function Clients() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getClients(), []) const groups = useMemo(() => data?.groups ?? [], [data]) - const [defaultPolicy, setDefaultPolicy] = useState('') + const [defaultPolicy] = useState('') const [defaultDraft, setDefaultDraft] = useState(null) const [editorOpen, setEditorOpen] = useState(false) const [draft, setDraft] = useState(EMPTY_DRAFT) @@ -302,7 +302,7 @@ export default function Clients() { title="Clients" description="Group clients by network, reverse-DNS name or tag and give each group its own policy." actions={ - } @@ -322,11 +322,11 @@ export default function Clients() { {groups.length === 0 ? ( } + icon={Users} title="No client groups" description="Without a group every client gets the default policy." action={ - } @@ -362,10 +362,10 @@ export default function Clients() {
- } label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} /> - } label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} /> + openEdit(i)} /> + duplicate(i)} /> } + icon={Trash2} label={`delete ${group.name}`} variant="ghost" onClick={() => setPendingDelete(i)} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 864c4a8..df9b60c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -23,11 +23,11 @@ import { } from 'recharts' import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react' -import { getDashboard } from '../../lib/api' -import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types' -import { useApi } from '../../hooks/useApi' -import { useAutoRefresh } from '../../hooks/useAutoRefresh' -import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange' +import { getDashboard } from '../lib/api' +import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../lib/types' +import { useApi } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange' import { formatClock, formatCompact, @@ -36,7 +36,7 @@ import { formatPercent, formatRate, formatUptime, -} from '../../lib/format' +} from '../lib/format' import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui' import { HealthPill, PageError, PageHeader } from '../components/layout' @@ -47,7 +47,6 @@ const QUERIES = '#38bdf8' const CACHED = '#34d399' const BLOCKED = '#f59e0b' const ERRORS = '#f87171' -const TIP_BG = '#0f172a' /** The parts of the dashboard payload that older daemons may not send. */ type DashboardView = Partial & { @@ -64,20 +63,6 @@ function view(data: DashboardData | null): DashboardView { return (data ?? {}) as DashboardView } -/** `useToast()` shape is the UI kit's business; this just needs a call. */ -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) -} - type TipProps = { active?: boolean label?: number | string @@ -222,7 +207,7 @@ export default function Dashboard() {
diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx index 82f501d..0129f93 100644 --- a/web/src/pages/Domains.tsx +++ b/web/src/pages/Domains.tsx @@ -10,15 +10,16 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { Plus, Search, Trash2 } from 'lucide-react' -import { addDomain, deleteDomain, getDomains } from '../../lib/api' -import type { AddDomainInput } from '../../lib/api' -import type { DomainItem } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useDebounced } from '../../hooks/useDebounced' -import { formatNumber, formatRelative, toLines } from '../../lib/format' +import { addDomain, deleteDomain, getDomains } from '../lib/api' +import type { AddDomainInput } from '../lib/api' +import type { DomainItem } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useDebounced } from '../hooks/useDebounced' +import { formatNumber, formatRelative, toLines } from '../lib/format' import { Badge, Button, + Card, ConfirmDialog, Dialog, EmptyState, @@ -30,21 +31,21 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const PER_PAGE = 50 @@ -88,7 +89,7 @@ type AddForm = { const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false } export default function Domains() { - const toast = useToast() + const { toast } = useToast() const [search, setSearch] = useState('') const [action, setAction] = useState('') const [list, setList] = useState('') @@ -220,7 +221,7 @@ export default function Domains() { updated: {formatRelative(item.updated_at)}, delete: ( } + icon={Trash2} label={`delete ${item.name}`} variant="ghost" onClick={() => setPendingDelete(item)} @@ -234,7 +235,7 @@ export default function Domains() { title="Domains" description="Inline allow and block rules, checked before the downloaded lists." actions={ - } @@ -245,7 +246,7 @@ export default function Domains() { } + icon={Search} value={search} onChange={(e) => setSearch(e.target.value)} autoComplete="off" diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..c74c317 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,182 @@ +/** + * Login — the only page that renders outside AppShell. + * + * The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the + * form is built from what `/api/whoami` reports rather than hard-coded. A 401 + * from that probe is expected (that is why the visitor is here) and is not + * shown as an error. + */ + +import { useEffect, useState } from 'react' +import type { FormEvent } from 'react' +import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react' +import { useSearchParams } from 'react-router-dom' + +import { ApiError, login, whoami } from '../lib/api' +import type { WhoamiResponse } from '../lib/api' +import { Button, Card, CardBody, Input } from '../components/ui' +import { Spinner } from '../components/ui' + +/** "apikey" -> one field, "basic" -> two, anything else -> one field. */ +function wantsPassword(mode: string): boolean { + return mode === 'basic' +} + +export default function Login() { + const [params] = useSearchParams() + const next = params.get('next') ?? '/' + + const [mode, setMode] = useState(null) + const [apiKey, setApiKey] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let live = true + whoami() + .then((who: WhoamiResponse) => { + if (live) setMode(who.auth_mode ?? 'apikey') + }) + .catch(() => { + // Unauthenticated probes and older daemons both land here: show the + // form rather than an error, the submit will say what is wrong. + if (live) setMode('apikey') + }) + return () => { + live = false + } + }, []) + + const onSubmit = async (event: FormEvent): Promise => { + event.preventDefault() + if (pending) return + setError(null) + setPending(true) + try { + if (wantsPassword(mode ?? 'apikey')) { + await login({ username, password }) + } else { + await login({ api_key: apiKey }) + } + // A full navigation: the shell reads whoami and the token on boot. + window.location.assign(next.startsWith('/') ? next : '/') + } catch (err) { + setError( + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : 'login failed', + ) + } finally { + setPending(false) + } + } + + const passwordMode = wantsPassword(mode ?? 'apikey') + + return ( +
+
+
+ + + fancy-dns +
+ + + +
void onSubmit(event)}> + {passwordMode ? ( + <> + setUsername(event.target.value)} + autoComplete="username" + autoFocus + required + /> + setPassword(event.target.value)} + autoComplete="current-password" + required + /> + + ) : ( + setApiKey(event.target.value)} + autoComplete="current-password" + spellCheck={false} + autoFocus + required + /> + )} + + {error !== null && ( +

+ {error} +

+ )} + + + +
+
+ +

+

+ + {pending && ( +

+ + waiting for the node +

+ )} +
+
+ ) +} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx new file mode 100644 index 0000000..e24527a --- /dev/null +++ b/web/src/pages/Logs.tsx @@ -0,0 +1,431 @@ +/** + * Logs — the query log, and the live tail of it. + * + * One table, two sources: the paged `/api/logs` result and the SSE stream from + * `/api/logs/stream`. While LIVE is on, streamed entries are prepended and run + * through the same filters client-side, so a live row can never contradict what + * the filter bar says. Rendering is capped: a busy node emits far faster than a + * browser can lay out rows, and an uncapped list is the tab's worst enemy. + */ + +import { useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { Eraser, Radio, Search, Signal, WifiOff } from 'lucide-react' + +import { clearLogs, getLogs, streamLogs } from '../lib/api' +import type { LogFilters, LogItem, LiveStats } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { useDebounced } from '../hooks/useDebounced' +import { + formatClock, + formatCompact, + formatMs, + formatNumber, + formatPercent, + formatRate, + formatRcode, +} from '../lib/format' +import { cn } from '../lib/cn' +import { + Badge, + Button, + Card, + CardBody, + ConfirmDialog, + Input, + Pagination, + Select, + Switch, + Table, + useToast, +} from '../components/ui' +import { PageError, PageHeader } from '../components/layout' + +type Row = Record + +const PER_PAGE = 50 +/** Hard cap on rendered rows, live tail included. */ +const MAX_ROWS = 300 +const REFRESH_LIVE_MS = 3000 + +const ACTION_OPTIONS = [ + { value: '', label: 'All actions' }, + { value: 'allow', label: 'Allow' }, + { value: 'cached', label: 'Cached' }, + { value: 'block', label: 'Block' }, + { value: 'error', label: 'Error' }, + { value: 'drop', label: 'Drop' }, +] + +/** The filter set, reduced to what both the server and the live tail need. */ +interface Filter { + q: string + action: string + client: string + blocked: boolean +} + +function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' { + switch (action) { + case 'allow': + return 'default' + case 'cached': + return 'info' + case 'block': + return 'warn' + case 'error': + return 'danger' + case 'drop': + return 'muted' + default: + return 'default' + } +} + +/** The server filters; the live tail re-implements the same rules. */ +function matches(entry: LogItem, filter: Filter): boolean { + if (filter.action !== '' && entry.action !== filter.action) return false + if (filter.blocked && entry.action !== 'block') return false + + if (filter.client !== '') { + const needle = filter.client.toLowerCase() + const hay = `${entry.client} ${entry.client_group}`.toLowerCase() + if (!hay.includes(needle)) return false + } + + if (filter.q !== '') { + const needle = filter.q.toLowerCase() + const hay = [ + entry.name, + entry.list, + entry.upstream, + entry.client, + entry.client_group, + entry.action, + ] + .join(' ') + .toLowerCase() + if (!hay.includes(needle)) return false + } + + return true +} + +/** Older daemons omit stats fields; a missing counter reads as zero, not NaN. */ +function liveStats(stats: LiveStats): LiveStats { + return { + qps: Number(stats.qps) || 0, + blocked_pct: Number(stats.blocked_pct) || 0, + cache_hit_rate: Number(stats.cache_hit_rate) || 0, + } +} + +export default function Logs() { + const { toast } = useToast() + const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => + toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) + + const [search, setSearch] = useState('') + const [action, setAction] = useState('') + const [client, setClient] = useState('') + const [blockedOnly, setBlockedOnly] = useState(false) + const [page, setPage] = useState(1) + const q = useDebounced(search, 300) + + const [live, setLive] = useState(false) + const [connected, setConnected] = useState(false) + const [streamError, setStreamError] = useState(null) + const [liveRows, setLiveRows] = useState([]) + const [stats, setStats] = useState(null) + const [confirmClear, setConfirmClear] = useState(false) + + const clear = useAsyncAction(async () => clearLogs()) + + // Every filter change invalidates both the current page and the live tail. + const applyFilter = (): void => { + setPage(1) + setLiveRows([]) + } + + const filters = useMemo( + () => ({ + q: q.trim() || undefined, + action: action === '' ? undefined : action, + client: client.trim() || undefined, + blocked: blockedOnly ? true : undefined, + limit: PER_PAGE, + offset: (page - 1) * PER_PAGE, + }), + [q, action, client, blockedOnly, page], + ) + + const { data, error, loading, refetch } = useApi(() => getLogs(filters), [filters]) + useAutoRefresh(refetch, REFRESH_LIVE_MS, live) + + // The stream outlives every filter change, so it reads the current filter + // through a ref instead of resubscribing on each keystroke. + const filterRef = useRef({ q: '', action: '', client: '', blocked: false }) + useEffect(() => { + filterRef.current = { q: q.trim(), action, client: client.trim(), blocked: blockedOnly } + }, [q, action, client, blockedOnly]) + + const toggleLive = (next: boolean): void => { + setLiveRows([]) + setStats(null) + setConnected(false) + setStreamError(null) + setLive(next) + } + + useEffect(() => { + if (!live) return + const stop = streamLogs({ + onEntry: (entry) => { + if (!matches(entry, filterRef.current)) return + setLiveRows((prev) => [entry, ...prev].slice(0, MAX_ROWS)) + }, + onStats: (next) => setStats(liveStats(next)), + onReady: () => setConnected(true), + onOpen: () => { + setConnected(true) + setStreamError(null) + }, + onError: (message) => { + setConnected(false) + setStreamError(message) + }, + }) + return stop + }, [live]) + + const items: LogItem[] = useMemo(() => data?.items ?? [], [data]) + const total = data?.total ?? items.length + const rows = useMemo( + () => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS), + [liveRows, items], + ) + + const onClear = async (): Promise => { + const res = await clear.run() + if (res === null) { + notify('Could not clear the log', clear.error ?? 'the server rejected the request') + return + } + setConfirmClear(false) + setLiveRows([]) + notify('Log cleared', 'the server dropped every buffered entry', 'ok') + refetch() + } + + if (error) return + + const columns = [ + { key: 'ts', header: 'Time', width: '9%' }, + { key: 'client', header: 'Client', width: '13%' }, + { key: 'group', header: 'Group', width: '10%' }, + { key: 'name', header: 'Name', width: '24%' }, + { key: 'qtype', header: 'Type', width: '6%' }, + { key: 'rcode', header: 'Rcode', width: '12%' }, + { key: 'action', header: 'Action', width: '8%' }, + { key: 'duration', header: 'Time', width: '8%', align: 'right' as const }, + { key: 'upstream', header: 'Upstream', width: '10%' }, + { key: 'list', header: 'List', width: '12%' }, + ] + + const tableRows: Row[] = rows.map((entry, index) => ({ + __key: `${entry.id}-${entry.ts}-${index}`, + ts: {formatClock(entry.ts)}, + client: ( + + {entry.client || '—'} + + ), + group: entry.client_group ? ( + {entry.client_group} + ) : ( + '—' + ), + name: ( + + {entry.name} + + ), + qtype: {entry.qtype || '—'}, + rcode: {formatRcode(entry.rcode)}, + action: {entry.action || 'unknown'}, + duration: {formatMs(entry.duration_ms)}, + upstream: ( + + {entry.upstream || '—'} + + ), + list: ( + + {entry.list || '—'} + + ), + })) + + const qps = stats?.qps ?? 0 + const blockedPct = stats?.blocked_pct ?? 0 + const hitRate = stats?.cache_hit_rate ?? 0 + + return ( +
+ + + + + +
+ } + /> + + {live && ( +
+ + + + {formatPercent(blockedPct)}{' '} + blocked + + + {formatRate(hitRate)} cache hits + + sampled by the daemon every few seconds + {streamError !== null && ( + + + )} +
+ )} + + + +
+ { + setSearch(event.target.value) + applyFilter() + }} + autoComplete="off" + spellCheck={false} + /> + { + setClient(event.target.value) + applyFilter() + }} + autoComplete="off" + spellCheck={false} + /> +
+ { + setBlockedOnly(next) + applyFilter() + }} + label="Blocked only" + description="Hide allowed and cached answers" + /> +
+
+
+
+ + = MAX_ROWS ? ( + capped at {MAX_ROWS} rows + ) : ( + + + ) + } + > +
+
String(row.__key)} + loading={loading && items.length === 0 && liveRows.length === 0} + maxHeight={640} + empty="No query matched these filters." + /> + + + + + + setConfirmClear(false)} + onConfirm={() => void onClear()} + title="Clear the query log?" + message="Every buffered entry is dropped on the server. This cannot be undone, and the running log keeps filling from the next query." + confirmLabel="Clear log" + destructive + pending={clear.pending} + /> + + ) +} diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..4eeb20b --- /dev/null +++ b/web/src/pages/NotFound.tsx @@ -0,0 +1,53 @@ +/** + * NotFound — the catch-all route. + */ + +import { Compass, LayoutDashboard } from 'lucide-react' +import { Link } from 'react-router-dom' + +import { Button, Card, CardBody, EmptyState } from '../components/ui' + +export default function NotFound() { + return ( +
+ + + + } + /> + + +
    + {[ + ['/', 'Dashboard'], + ['/blocklists', 'Blocklists'], + ['/domains', 'Domains'], + ['/clients', 'Clients'], + ['/upstreams', 'Upstreams'], + ['/logs', 'Logs'], + ['/cache', 'Cache'], + ['/settings', 'Settings'], + ].map(([to, label]) => ( +
  • + + {to} + {label} + +
  • + ))} +
+
+
+
+ ) +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..7f239bf --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,471 @@ +/** + * Settings — the YAML that the daemon was started from. + * + * The editor is deliberately plain: a line-numbered textarea rather than a + * YAML widget, because the operator's file already has comments and ordering + * that a form would silently destroy. Validation is server-side, so what is + * reported here is exactly what the daemon would do with the file. + */ + +import { useMemo, useRef, useState } from 'react' +import type { UIEvent } from 'react' +import { + Ban, + CheckCircle2, + Database, + FileCode2, + Info, + RefreshCw, + Save, + ShieldAlert, + TriangleAlert, +} from 'lucide-react' + +import { + flushCache, + getSettings, + putSettings, + refreshBlocklists, + reloadSettings, + validateSettings, +} from '../lib/api' +import type { ConfigIssue } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { formatNumber } from '../lib/format' +import { + Badge, + Button, + Card, + CardBody, + ConfirmDialog, + CopyButton, + Spinner, + Textarea, + useToast, +} from '../components/ui' +import { LoadingBlock, PageError, PageHeader } from '../components/layout' + +interface Report { + errors: ConfigIssue[] + warnings: ConfigIssue[] +} + +/** A validation list is only usable if every entry has a message. */ +function issues(list: ConfigIssue[] | undefined): ConfigIssue[] { + if (!Array.isArray(list)) return [] + return list.filter((item) => item != null && typeof item.message === 'string' && item.message !== '') +} + +function IssueList({ issues: list, tone }: { issues: ConfigIssue[]; tone: 'danger' | 'warn' }) { + return ( +
    + {list.map((issue, index) => ( +
  • + {tone === 'danger' ? ( +
  • + ))} +
+ ) +} + +export default function Settings() { + const { toast } = useToast() + const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => + toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) + + const { data, error, loading, refetch } = useApi(() => getSettings(), []) + // The draft is keyed by the version it was started from: once the server + // reports a different version the edit is dropped, so a refetch can never + // stomp on an editor that is mid-change. + const [draft, setDraft] = useState<{ version: string; text: string } | null>(null) + const gutter = useRef(null) + const [report, setReport] = useState(null) + const [confirmReload, setConfirmReload] = useState(false) + + const validate = useAsyncAction(async (yaml: string) => validateSettings(yaml)) + const save = useAsyncAction(async (yaml: string) => putSettings(yaml)) + const reload = useAsyncAction(async () => reloadSettings()) + const flush = useAsyncAction(async () => flushCache()) + const refreshLists = useAsyncAction(async () => refreshBlocklists()) + + const pristine = data?.config_yaml ?? '' + const version = data?.version ?? '' + const value = draft !== null && draft.version === version ? draft.text : pristine + const dirty = data !== null && value !== pristine + const writable = data?.writable !== false + const lineCount = useMemo(() => value.split('\n').length, [value]) + const gutterLines = useMemo(() => Array.from({ length: lineCount }, (_, i) => i + 1), [lineCount]) + + // The gutter is a plain div; keep its scroll offset glued to the textarea's. + const onScroll = (event: UIEvent): void => { + const element = gutter.current + if (element !== null) element.scrollTop = event.currentTarget.scrollTop + } + + const onValidate = async (): Promise => { + const res = await validate.run(value) + if (res === null) { + setReport(null) + notify('Validation failed', validate.error ?? 'the server could not check this file') + return + } + const found: Report = { errors: issues(res.errors), warnings: issues(res.warnings) } + setReport(found) + if (found.errors.length === 0) { + notify( + 'Configuration is valid', + found.warnings.length > 0 + ? `${found.warnings.length} ${found.warnings.length === 1 ? 'warning' : 'warnings'}` + : 'the daemon would accept this file', + 'ok', + ) + return + } + notify( + 'Configuration is not valid', + `${found.errors.length} ${found.errors.length === 1 ? 'error' : 'errors'}, ${found.warnings.length} warnings`, + ) + } + + const onSave = async (): Promise => { + const res = await save.run(value) + if (res === null) { + notify('Could not save', save.error ?? 'the daemon rejected the write') + return + } + const list = Array.isArray(res.warnings) ? res.warnings : [] + notify( + 'Configuration saved', + list.length > 0 + ? `${list.length} ${list.length === 1 ? 'warning' : 'warnings'}: ${list.join('; ')}` + : `version ${res.version || 'unknown'}`, + 'ok', + ) + setDraft(null) + setReport(null) + refetch() + } + + const onReload = async (): Promise => { + const res = await reload.run() + setConfirmReload(false) + if (res === null) { + notify('Reload failed', reload.error ?? 'the daemon kept the running configuration') + return + } + setDraft(null) + setReport(null) + notify('Configuration reloaded', `running version ${res.version || 'unknown'}`, 'ok') + refetch() + } + + const onFlush = async (): Promise => { + const res = await flush.run() + if (res === null) { + notify('Could not flush the cache', flush.error ?? 'the server rejected the request') + return + } + notify( + 'Cache flushed', + `${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`, + 'ok', + ) + } + + const onRefreshLists = async (): Promise => { + const res = await refreshLists.run() + if (res === null) { + notify('Refresh failed', refreshLists.error ?? 'the server rejected the request') + return + } + notify('Refresh started', `${res.names?.length ?? 0} sources queued`, 'ok') + } + + if (error) return + + if (loading && data === null) { + return ( +
+ + + + + + +
+ ) + } + + const effective = data?.effective_yaml ?? '' + const defaults = data?.defaults_yaml ?? '' + + return ( +
+ + {writable ? 'writable' : 'read-only'} + +
+ } + /> + +
+ + + version {data?.version || 'unknown'} + {data?.reload_available === false && ( + the daemon cannot reload this file at runtime + )} +
+ + {data?.last_error ? ( +
+
+ ) : null} + +
+ {dirty ? 'modified' : 'in sync'}} + > +
+ +