Fix/web typecheck #1

Merged
mats merged 5 commits from fix/web-typecheck into main 2026-09-27 15:01:51 +02:00
35 changed files with 6077 additions and 666 deletions

No files matched your search

Binary file not shown.
+35 -3
View File
@@ -1,8 +1,10 @@
{ {
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns", "root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
"recent_files": [ "recent_files": [
"internal/server/stats.go", "web/src/pages/Upstreams.tsx",
"internal/config/config.go" "internal/config/config.go",
"internal/app/harness_test.go",
"internal/server/stats.go"
], ],
"queries": [ "queries": [
{ {
@@ -16,8 +18,38 @@
"command": "grep", "command": "grep",
"at": "2026-09-27T11:00:17.585327659Z", "at": "2026-09-27T11:00:17.585327659Z",
"focus_path": "internal/server/stats.go" "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": [], "ast_pins": [],
"updated_at": "2026-09-27T11:00:17.585333149Z" "updated_at": "2026-09-27T11:28:04.46305757Z"
} }
+203
View File
@@ -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 '<!doctype html'
echo "--- unauthenticated admin API is refused"
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/api/whoami)
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
echo "--- authenticated admin API works"
curl -fsS -H "X-API-Key: CHANGE-ME-long-random-string" \
http://127.0.0.1:18080/api/whoami | grep -q '"can_write":true'
echo "smoke test passed"
- name: Upload the binary
uses: actions/upload-artifact@v4
with:
name: fancy-dns-linux-amd64
path: fancy-dns
retention-days: 14
image:
name: Container image
runs-on: ubuntu-latest
needs: [binary]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to the registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
build-args: |
VERSION=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+37
View File
@@ -0,0 +1,37 @@
# Build output
/fancy-dns
/dist/
*.test
*.out
coverage.*
# The console bundle is a build artifact consumed by //go:embed. The directory
# itself must be committed (the embed fails without it), so keep the placeholder
# and ignore the generated bundle.
/internal/web/dist/*
!/internal/web/dist/.gitkeep
/web/dist/
/web/node_modules/
/web/.vite/
# Runtime state: bbolt files, downloaded lists, rendered config.
/data/
*.db
*.db.lock
config.local.yaml
# TLS material must never be committed.
/tls/
*.pem
*.key
!deploy/**/*.key.example
# Tooling scratch state
/.codesearch/
# Editor and OS noise
.DS_Store
*.swp
.idea/
.vscode/
*.iml
+79
View File
@@ -0,0 +1,79 @@
# syntax=docker/dockerfile:1.7
#
# Two stages: build the console, then build a static Go binary with it
# embedded. The result is a single file with no runtime dependencies beyond
# libc, which is what makes a scratch/distroless base safe.
ARG NODE_IMAGE=node:22-alpine
ARG GO_IMAGE=golang:1.23-alpine
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot
# ---- stage 1: the admin console -------------------------------------------
FROM ${NODE_IMAGE} AS web
WORKDIR /src/web
# Dependencies first: this layer is only rebuilt when package-lock.json moves,
# not on every source edit.
COPY web/package.json web/package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY web/ ./
# vite.config.ts writes to ../internal/web/dist, which is where the Go embed
# directive looks.
RUN mkdir -p /src/internal/web && npm run build \
&& test -f /src/internal/web/dist/index.html
# ---- stage 2: the binary ---------------------------------------------------
FROM ${GO_IMAGE} AS build
ARG VERSION=dev
ARG TARGETOS=linux
ARG TARGETARCH=amd64
WORKDIR /src
# The placeholder dist directory keeps the embed directive valid even if the
# console stage was skipped.
COPY internal/web/dist/ internal/web/dist/
COPY --from=web /src/internal/web/dist/ internal/web/dist/
# Module cache first, for the same reason.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO off: the resolver, bbolt and the TLS stack are all pure Go, and a static
# binary is what lets the runtime stage be distroless-static.
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath \
-ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${VERSION}" \
-o /out/fancy-dns ./cmd/fancy-dns
# ---- stage 3: runtime ------------------------------------------------------
FROM ${RUNTIME_IMAGE}
# 53 is the DNS service itself, 853 is DoT, 443 is DoH, 8080 is the console.
EXPOSE 53/udp 53/tcp
EXPOSE 853/tcp
EXPOSE 443/tcp
EXPOSE 8080/tcp
# The bbolt files and downloaded lists live here; mount a volume over it.
VOLUME ["/var/lib/fancy-dns"]
COPY --from=build /out/fancy-dns /usr/local/bin/fancy-dns
COPY --from=build /src/deploy/config.example.yaml /etc/fancy-dns/config.example.yaml
COPY --from=build /src/deploy/zones/ /etc/fancy-dns/zones/
USER nonroot:nonroot
# The liveness probe, not the readiness one: it answers 200 while the process
# is up, which is what a crash-loop detector wants.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/fancy-dns", "-version"]
ENTRYPOINT ["/usr/local/bin/fancy-dns"]
CMD ["-config", "/etc/fancy-dns/config.yaml"]
+137
View File
@@ -0,0 +1,137 @@
# fancy-dns
#
# `make` alone gives you a help screen. The two targets you want most are
# `make build` (binary with the console embedded) and `make check` (what CI
# runs).
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
BINARY := fancy-dns
PKG := git.k8s-git-k6n.net/mats/fancy-dns
CMD := ./cmd/fancy-dns
VERSION ?= $(shell git describe --tags --always --dirty 2>/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 <target>\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
+162 -115
View File
@@ -1,8 +1,8 @@
# fancy-dns — development status and continuation plan # fancy-dns — development status and continuation plan
Last updated: session checkpoint. Everything below reflects the state of the Last updated: end of the race-fix / test-coverage session. Everything below
working tree at the time of writing, including known-failing tests and the reflects the working tree, including the known-failing tests and the exact
exact next command to run. commands to reproduce them.
## Goal ## Goal
@@ -47,8 +47,8 @@ internal/
api/ admin REST + SSE, auth, CORS, rate limiting api/ admin REST + SSE, auth, CORS, rate limiting
app/ wiring, lifecycle, API hook implementations app/ wiring, lifecycle, API hook implementations
web/ go:embed of the built console web/ go:embed of the built console
web/ Vite + React + TS + Tailwind console (subagent-built) web/ Vite + React + TS + Tailwind console
deploy/ example config, example zones deploy/ example config, example zones, k8s manifests
``` ```
### Query pipeline order (engine.Handle) ### 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 ### Done and verified
- All packages compile; `go vet ./...` clean. - `go build ./...`, `go vet ./...` and `gofmt -l` are all clean.
- `go test ./internal/app/` **passes** (integration suite, ~20 tests) with a fake - **`go test -race ./internal/app/` is green — zero data races.** The two
upstream on loopback: resolve/caching, all blocklist shapes, allow-over-block, `-race`-only failures and all ten reported races are fixed (see below).
local zones (exact/wildcard/NODATA/NXDOMAIN), ACL deny + allow_domains + - `go test ./internal/zonefile/` is green (the full parser suite).
skip_blocklist, all four block actions, malformed query FORMERR, TCP parity, - Frontend **builds and type-checks**: `npx tsc -b --noEmit` and `npm run build`
API auth (401/403/200), domain add→blocked→delete round trip, ACL update, both exit 0, `npx oxlint` reports 0 errors / 10 warnings, and the real bundle
upstream update, settings validate/save + rejected-write-leaves-file-alone, lands in `internal/web/dist/`. The `//go:embed all:dist` picks it up with no
blocklist toggle both directions, dashboard/status shape, logs, cache Go change.
endpoints, reload failure keeps serving + success clears error, SSE stream - One real frontend bug fixed along the way: four pages used a `toast(string)`
emits `ready` and `entry`, metrics protected/unprotected, concurrency smoke. duck-typed shim while `useToast()` takes `ToastOptions`, so every mutation
- Verified live end-to-end against the real binary on :15353/:18080 with dig. toast rendered an empty title.
- Subagent-verified working examples of `/api/domains/check` for hosts-file,
RPZ-wildcard and inline rules.
### 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.** All of these were found by `-race` and fixed at the source, not with extra
- `TestDomainAddDeleteRoundTrip` and `TestUpstreamUpdate` fail **only under locking:
`-race`** (they pass without it), which points at timing, not logic.
- I had just started fixing races by moving `engine.sem`, `engine.semSize` 1. **`cache.Entry.Hits` → `atomic.Uint32`.** A counter on an entry shared by
and `engine.upstreamTimeout` behind atomics. The `applyConfig` hook now every reader raced the LRU walk used by the API. The struct is now never
writes `e.sem.Store(&sem)`; `Handle` does `sem := e.sem.Load()`. **This copied by value.
refactor is mid-flight** — re-read `internal/engine/engine.go` before 2. **`LRU.perShard` → `atomic.Int64`.** `SetCapacity` (a config reload, from the
touching it. Remaining races are almost certainly in API goroutine) rewrote the per-shard budget while every query shard was
`config.Config` field access from `applyConfig` (e.g. `a.pool.Rebind` inserting.
called from the swap hook while queries read `cfg`), and in 3. **`LRU.SetCapacity` now evicts.** Shrinking the budget changed the number but
`harness_test.go` reading the config file the server rewrites (partially left the old entries resident until they happened to be overwritten; a
fixed by snapshotting `doc` before `Start` — verify). reload that lowers `cache.size` could not actually reclaim memory. It now
- `internal/app/dbg_test.go` was deleted; do not reintroduce it. trims each shard to the new budget and counts the evictions.
2. **Frontend `web/` is incomplete.** The foundation/pages subagents each ran 4. **`resolver.Pool` rewritten as copy-on-write.** This was the bulk of the
out of budget. `src/lib/types.ts`, `format.ts`, `cn.ts`, `vite.config.ts` races: `Rebind` mutated `p.ups` / `p.cfg` under a mutex that the query path
exist; `src/index.css` is still the purple Vite template; `src/lib/api.ts`, never took, so `Exchange`, `Healthy`, `All` and the `dns.Client` memo map
`src/hooks/*`, `src/components/ui/*`, `src/components/layout/*` and all of were all racing. The pool now holds an immutable `poolState` behind
`src/pages/*` are missing. `npm run build` and `npx tsc -b --noEmit` have `atomic.Pointer`; `Rebind` builds the next state and swaps it with one
never passed. `internal/web/dist/index.html` is a placeholder so the binary store. `Upstream` is immutable and holds a shared `*upstreamStats`, so the
compiles. live health, latency and breaker state of an unchanged server survives a
3. **No unit tests outside `internal/app/`.** `blocklist`, `cache`, `zonefile`, reload while a query in flight keeps a consistent view.
`acl`, `resolver`, `localzone` have no direct tests. 5. **`resolver.Pool.Exchange` no longer marks dead upstreams healthy.** A
4. **Missing deliverables:** `Makefile`, `.gitignore`, `Dockerfile`, k8s pre-existing bug: `exchangeOne` called `up.Success(rtt)` before checking the
manifests, CI workflow, `README.md`. 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 ## Continuation plan, in order
### Step 1 — green tests (highest priority) ### Step 1 — the bbolt round trip (highest priority)
```bash ```bash
go build ./... && go vet ./... go test -race ./internal/cache/ -run 'TestStore' -count=1 -v
go test -race ./internal/app/ -count=1 2>&1 | grep -A20 "DATA RACE" | head -60
``` ```
Expected races to fix, in likelihood order: Read `Snapshot` and `Load` in `internal/cache/store.go` as a pair and make the
- `config.Store` swap hook running `app.onConfigSwap` → `pool.Rebind` and record format explicit — a small `encodeRecord`/`decodeRecord` pair with one
`reloadLocalZones` while the query path reads the old `*config.Config`. version byte, so a future change cannot silently desync them the way this one
Fix direction: make `Pool.Rebind` build a new slice and swap an did. Then add a `t.TempDir()` round-trip test that survives a process restart
`atomic.Pointer` rather than mutating in place under `sync.RWMutex` while and a partially written file.
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.
For each race, prefer removing shared mutable state over adding locks: the ### Step 2 — the five blocklist gaps
design already uses `atomic.Pointer` holders for ACL, blocklist, localzone and
pool; extend that pattern.
### 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): ### Step 3 — the remaining unit tests
- `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 — 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; ### Step 4 — review the deploy set and write the README
they just need more budget):
- **Foundation agent** owns `src/index.css` (replace the purple template with The console pages exist and build; verify them in a browser against a running
dark-first CSS vars + one emerald accent), `src/lib/api.ts`, `src/hooks/*`, binary. Then read `Makefile`, `Dockerfile` and `.github/workflows/ci.yml`
`src/components/ui/*`, `src/components/layout/*`. The exact prop contract I against the code and fix what does not match, add the missing k8s manifests
gave them is in the transcript and must be kept. (Service, PVC, ServiceAccount + Secret, Ingress with `proxy_buffering off` for
- **Pages agent** owns `src/pages/*` only, coding against the same contract. `/api/logs/stream`, namespace, kustomization), and write `README.md` from the
source: config reference, zone-file semantics, blocklist formats, API routes,
Then wire `src/App.tsx` + `src/main.tsx` yourself (react-router, `AppShell`, the `trusted_proxies` / `metrics.protect` / `auth.mode` security caveats, and
`ToastProvider`, theme init) and run `npm run build`. Once `web/dist` is real, the limitations (no DNSSEC validation, no zone transfer, no dynamic update).
the `//go:embed all:dist` in `internal/web/web.go` picks it up with no Go CI must run `npm ci && npm run build` before `go build`, because the Go build
change; delete the placeholder `internal/web/dist/index.html`. embeds `internal/web/dist`.
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).
## Design notes worth preserving ## Design notes worth preserving
- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one - **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one `Unpack`
`Unpack` and yields a private copy, so serving never mutates shared state and and yields a private copy, so serving never mutates shared state and TTL
TTL ageing is free. Same trade dnsmasq makes. ageing is free. Same trade dnsmasq makes.
- **Cache invalidation on rule changes flushes the cache** rather than - **Everything the query path reads is behind an `atomic.Pointer` holder** —
guessing which subtrees are affected. A newly blocked name must not keep ACL, blocklist, localzone, and now the upstream pool. Readers load the
serving its cached good answer, and a new allow rule can rescue a name many pointer once and work against a value that cannot change under them. This is
lists blocked. `invalidateBlocklistCache()` in `internal/app/hooks.go`. 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 - **API responses share the field names in `internal/app/hooks.go`.** If you
rename one, grep the frontend too. rename one, grep the frontend too.
- **API keys are compared by SHA-256 hash, constant-time**, and - **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. client could spoof its identity past the ACL and the rate limiter.
- **UDP listener resets its read deadline every iteration.** A deadline set - **UDP listener resets its read deadline every iteration.** A deadline set
once expires permanently and turns the loop into a spin that never reads 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 - **Only the Valkey lease holder fetches lists**, so N instances do not hit a
mirror N times. Followers serve their compiled copy. mirror N times. Followers serve their compiled copy.
- **Blocked/serve-stale behaviour:** a served-stale answer gets TTL 1, an - **Blocked/serve-stale behaviour:** a served-stale answer gets TTL 1, an
expired upstream answer is never cached, and a truncated answer is never expired upstream answer is never cached, and a truncated answer is never
cached (it would be served truncated forever). 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.
+120
View File
@@ -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: ""
+245
View File
@@ -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
+579
View File
@@ -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 <domain>" 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)
}
}
}
+34 -14
View File
@@ -214,13 +214,16 @@ type SourceMeta struct {
// Builder accumulates rules and produces a Set. // Builder accumulates rules and produces a Set.
type Builder struct { type Builder struct {
exact map[string]*Entry exact map[string]*Entry
sub map[string]*Entry sub map[string]*Entry
root map[string]*Entry root map[string]*Entry
meta map[string]SourceMeta meta map[string]SourceMeta
order []string order []string
dup int // ordered mirrors order, so a source is listed once even when both a rule
counts RuleCounts // and its metadata mention it.
ordered map[string]bool
dup int
counts RuleCounts
} }
// NewBuilder returns an empty Builder. // NewBuilder returns an empty Builder.
@@ -286,9 +289,7 @@ func (b *Builder) Add(r Rule, source string, categories []string) {
} }
m[name] = e m[name] = e
b.countAction(e, 1) b.countAction(e, 1)
if _, seen := b.meta[source]; !seen { b.noteOrder(source)
b.order = append(b.order, source)
}
} }
// metaFor reports whether a source already contributed to this build. // 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. // NoteSource records (or updates) bookkeeping for a contributing source.
func (b *Builder) NoteSource(meta SourceMeta) { func (b *Builder) NoteSource(meta SourceMeta) {
if _, seen := b.meta[meta.Name]; !seen { b.noteOrder(meta.Name)
b.order = append(b.order, meta.Name)
}
b.meta[meta.Name] = meta 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. // SourceCount is the number of contributing sources.
func (b *Builder) SourceCount() int { return len(b.meta) } 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). // 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. // Set installs a new set.
func (h *Holder) Set(s *Set) { h.cur.Store(s) } func (h *Holder) Set(s *Set) { h.cur.Store(s) }
+525
View File
@@ -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")
}
}
+9 -3
View File
@@ -18,6 +18,7 @@ package cache
import ( import (
"encoding/binary" "encoding/binary"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -89,7 +90,9 @@ func canonical(name string) string {
// //
// varint len | wire name | uint16 type | uint16 class | uint8 flags // varint len | wire name | uint16 type | uint16 class | uint8 flags
func packKey(buf []byte, k Key) ([]byte, error) { 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 { if n := len(name); n > 255*4 {
return nil, errKeyTooLong return nil, errKeyTooLong
} }
@@ -160,8 +163,11 @@ type Entry struct {
StaleUntil time.Time StaleUntil time.Time
// Bytes is the accounted size, used by the LRU and the size gauges. // Bytes is the accounted size, used by the LRU and the size gauges.
Bytes int Bytes int
// Hits counts serves since it was stored. // Hits counts serves since it was stored. It is atomic because an Entry is
Hits uint32 // 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 marks a cached NXDOMAIN or NODATA answer.
Neg bool Neg bool
// Truncated records that the upstream answer did not fit in 512 bytes, // Truncated records that the upstream answer did not fit in 512 bytes,
+870
View File
@@ -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
}
})
}
+36 -10
View File
@@ -22,8 +22,10 @@ type LRU struct {
shards []*shard shards []*shard
mask uint64 mask uint64
// perShard is the per-shard entry budget. // perShard is the per-shard entry budget. It is atomic because a config
perShard int // 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 // counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit. // the lock just to record a hit.
@@ -69,13 +71,10 @@ func NewLRU(size, shards int) *LRU {
l := &LRU{ l := &LRU{
shards: make([]*shard, shards), shards: make([]*shard, shards),
mask: uint64(shards - 1), mask: uint64(shards - 1),
perShard: size / shards,
seed: maphash.MakeSeed(), seed: maphash.MakeSeed(),
prefetchGate: make(chan struct{}, 64), prefetchGate: make(chan struct{}, 64),
} }
if l.perShard < 1 { l.perShard.Store(int64(max(1, size/shards)))
l.perShard = 1
}
for i := range l.shards { for i := range l.shards {
l.shards[i] = &shard{ l.shards[i] = &shard{
items: make(map[Key]*list.Element, 1024), 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() s.mu.Unlock()
e.Hits++ e.Hits.Add(1)
if kind == Fresh { if kind == Fresh {
l.hits.Add(1) l.hits.Add(1)
} else { } else {
@@ -171,7 +170,8 @@ func (l *LRU) Put(k Key, e *Entry) {
s.bytes += e.Bytes s.bytes += e.Bytes
l.bytes.Add(int64(e.Bytes)) l.bytes.Add(int64(e.Bytes))
var dropped int var dropped int
for s.ll.Len() > l.perShard { budget := int(l.perShard.Load())
for s.ll.Len() > budget {
oldest := s.ll.Back() oldest := s.ll.Back()
if oldest == nil { if oldest == nil {
break break
@@ -347,7 +347,7 @@ func (l *LRU) Stats() LRUStats {
Expired: l.expired.Load(), Expired: l.expired.Load(),
HitRate: rate, HitRate: rate,
Shards: len(l.shards), 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 = len(l.shards)
} }
shards = roundPow2(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))
} }
+16 -6
View File
@@ -90,8 +90,11 @@ type Logger struct {
// seq assigns monotonic ids so the UI can detect drops. // seq assigns monotonic ids so the UI can detect drops.
seq atomic.Uint64 seq atomic.Uint64
// live is a fixed-size ring of the most recent entries. // live is a fixed-size ring of the most recent entries. Each slot is a
live []liveEntry // 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 livePos atomic.Uint32
liveMu sync.RWMutex liveMu sync.RWMutex
liveCond *sync.Cond liveCond *sync.Cond
@@ -184,7 +187,7 @@ func New(opt Options) (*Logger, error) {
} }
l := &Logger{ l := &Logger{
opts: opt, opts: opt,
live: make([]liveEntry, opt.LiveBuffer), live: make([]atomic.Pointer[liveEntry], opt.LiveBuffer),
counts: newDomainCounts(opt.TopDomains, opt.TopTTL), counts: newDomainCounts(opt.TopDomains, opt.TopTTL),
subs: make(map[int]chan Entry), subs: make(map[int]chan Entry),
stop: make(chan struct{}), stop: make(chan struct{}),
@@ -258,9 +261,11 @@ func (l *Logger) Log(e Entry) {
e.ID = l.seq.Add(1) e.ID = l.seq.Add(1)
l.counts.observe(e) 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 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 // Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path). // rather than stalling the query path).
@@ -435,7 +440,12 @@ func (l *Logger) Recent(limit int) []Entry {
} }
for i := uint32(0); i < total; i++ { for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live)) 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 { if len(out) >= limit {
break break
} }
+299 -262
View File
@@ -26,6 +26,10 @@ var ErrNoUpstream = errors.New("resolver: no healthy upstream available")
// ErrCircuitOpen is returned by a single upstream whose breaker is open. // ErrCircuitOpen is returned by a single upstream whose breaker is open.
var ErrCircuitOpen = errors.New("resolver: upstream circuit breaker 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. // Health is an upstream's current state.
type Health string type Health string
@@ -40,28 +44,46 @@ const (
HealthUnknown Health = "unknown" 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 { type Upstream struct {
cfg config.UpstreamServer cfg config.UpstreamServer
// addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps // addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps
// the hostname) and the literal address otherwise. // the hostname) and the literal address otherwise.
addr string addr string
// resolved holds the current bootstrap addresses when the configured // resolved holds the bootstrap addresses behind a named upstream.
// address is a name.
resolved []string resolved []string
stats *upstreamStats
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]
} }
// Addr is the address the resolver dials. // 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. // Config returns the upstream's configuration entry.
func (u *Upstream) Config() config.UpstreamServer { return u.cfg } 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. // Health returns the current health state.
func (u *Upstream) Health() Health { 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 *h
} }
return HealthUnknown 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. // SetError records the last error message for the UI.
func (u *Upstream) SetError(err error) { func (u *Upstream) SetError(err error) {
if err == nil { if err == nil {
u.lastErr.Store(nil) u.stats.lastErr.Store(nil)
return return
} }
s := err.Error() s := err.Error()
u.lastErr.Store(&s) u.stats.lastErr.Store(&s)
} }
// LastError returns the last error message, or "". // LastError returns the last error message, or "".
func (u *Upstream) LastError() string { func (u *Upstream) LastError() string {
if p := u.lastErr.Load(); p != nil { if p := u.stats.lastErr.Load(); p != nil {
return *p return *p
} }
return "" return ""
@@ -100,10 +128,11 @@ func (u *Upstream) LastError() string {
// Success records a successful query. // Success records a successful query.
func (u *Upstream) Success(d time.Duration) { func (u *Upstream) Success(d time.Duration) {
u.queries.Add(1) s := u.stats
u.consecutive.Store(0) s.queries.Add(1)
u.latencySum.Add(uint64(d.Microseconds())) s.consecutive.Store(0)
u.latencyN.Add(1) s.latencySum.Add(uint64(d.Microseconds()))
s.latencyN.Add(1)
u.recordLatency(d) u.recordLatency(d)
u.SetError(nil) u.SetError(nil)
if u.Health() != HealthUp { 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 // Failure records a failed query and trips the breaker once the threshold is
// crossed. // crossed.
func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) { func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) {
u.queries.Add(1) s := u.stats
u.failures.Add(1) s.queries.Add(1)
n := u.consecutive.Add(1) s.failures.Add(1)
n := s.consecutive.Add(1)
u.SetError(err) u.SetError(err)
if threshold > 0 && int(n) >= threshold { 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) u.setHealth(HealthDown)
return 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. // BreakerOpen reports whether the upstream's breaker is open.
func (u *Upstream) BreakerOpen() bool { func (u *Upstream) BreakerOpen() bool {
until := u.breakerOpenUntil.Load() return u.stats.breakerOpenUntil.Load() > time.Now().UnixNano()
return until > time.Now().UnixNano()
} }
func (u *Upstream) recordLatency(d time.Duration) { func (u *Upstream) recordLatency(d time.Duration) {
ms := float64(d.Microseconds()) / 1000 ms := float64(d.Microseconds()) / 1000
u.p95mu.Lock() s := u.stats
if len(u.p95ring) < 512 { s.mu.Lock()
u.p95ring = append(u.p95ring, ms) if len(s.ring) < 512 {
s.ring = append(s.ring, ms)
} else { } else {
copy(u.p95ring, u.p95ring[1:]) copy(s.ring, s.ring[1:])
u.p95ring[511] = ms s.ring[511] = ms
} }
u.p95mu.Unlock() s.mu.Unlock()
} }
// LatencyStats returns average and p95 latency in milliseconds. // LatencyStats returns average and p95 latency in milliseconds.
func (u *Upstream) LatencyStats() (avg, p95 float64) { func (u *Upstream) LatencyStats() (avg, p95 float64) {
n := u.latencyN.Load() s := u.stats
n := s.latencyN.Load()
if n == 0 { if n == 0 {
return 0, 0 return 0, 0
} }
avg = float64(u.latencySum.Load()) / float64(n) / 1000 avg = float64(s.latencySum.Load()) / float64(n) / 1000
u.p95mu.Lock() s.mu.Lock()
samples := append([]float64(nil), u.p95ring...) samples := append([]float64(nil), s.ring...)
u.p95mu.Unlock() s.mu.Unlock()
if len(samples) == 0 { if len(samples) == 0 {
return avg, 0 return avg, 0
} }
@@ -187,8 +218,11 @@ type Stats struct {
// Stats snapshots one upstream. // Stats snapshots one upstream.
func (u *Upstream) Stats() Stats { func (u *Upstream) Stats() Stats {
q := u.queries.Load() if u == nil {
f := u.failures.Load() return Stats{}
}
q := u.stats.queries.Load()
f := u.stats.failures.Load()
var rate float64 var rate float64
if q > 0 { if q > 0 {
rate = float64(f) / float64(q) 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. // poolState is an immutable snapshot of the pool: the config it was built
type Pool struct { // from, the upstreams, and the address index used for per-client upstream
cfg config.Upstream // overrides. Readers take the pointer once and are then immune to reloads.
mu sync.RWMutex type poolState struct {
ups []*Upstream cfg config.Upstream
byTag map[string][]*Upstream ups []*Upstream
// clients are reused per upstream: one UDP socket, one TCP dialer, one TLS // byAddr indexes the upstreams by every address they answer to, so an ACL
// session pool. Reusing them is what keeps the per-query cost low. // group naming either the configured Addr or the resolved one works.
clients map[*Upstream]*dns.Client byAddr map[string][]*Upstream
// bootstrap resolves names to addresses.
bootstrap map[string][]string
rng *rand.Rand
mu2 sync.Mutex
} }
// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream func (st *poolState) all() []*Upstream {
// hostnames must be literal IPs (or resolved by the OS). 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 { func NewPool(cfg config.Upstream) *Pool {
p := &Pool{ p := &Pool{
cfg: cfg, rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)),
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)),
} }
for _, b := range cfg.Bootstraps { p.state.Store(buildState(cfg, nil, false))
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()
return p return p
} }
func (p *Pool) resolveAll() { // buildState turns an upstream config into an immutable pool snapshot. When
for _, up := range p.ups { // carry is non-nil, servers that keep the same address and protocol also keep
host, port, err := net.SplitHostPort(up.cfg.Addr) // their live health, latency and breaker state.
if err != nil { func buildState(cfg config.Upstream, carry *poolState, lookup bool) *poolState {
continue bootstraps := make(map[string][]string, len(cfg.Bootstraps))
} for _, b := range cfg.Bootstraps {
if net.ParseIP(host) != nil { bootstraps[b.Name] = append([]string(nil), b.Addresses...)
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)
} }
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. // 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. // 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 { 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 { if u.addr == addr || u.cfg.Addr == addr {
return u return u
} }
@@ -298,8 +376,9 @@ func (p *Pool) Get(addr string) *Upstream {
// Healthy returns upstreams whose breaker is closed, in configured order. // Healthy returns upstreams whose breaker is closed, in configured order.
func (p *Pool) Healthy() []*Upstream { func (p *Pool) Healthy() []*Upstream {
out := make([]*Upstream, 0, len(p.ups)) ups := p.All()
for _, u := range p.ups { out := make([]*Upstream, 0, len(ups))
for _, u := range ups {
if !u.BreakerOpen() { if !u.BreakerOpen() {
out = append(out, u) out = append(out, u)
} }
@@ -309,8 +388,9 @@ func (p *Pool) Healthy() []*Upstream {
// Stats snapshots every upstream. // Stats snapshots every upstream.
func (p *Pool) Stats() []Stats { func (p *Pool) Stats() []Stats {
out := make([]Stats, 0, len(p.ups)) ups := p.All()
for _, u := range p.ups { out := make([]Stats, 0, len(ups))
for _, u := range ups {
out = append(out, u.Stats()) out = append(out, u.Stats())
} }
return out return out
@@ -318,95 +398,54 @@ func (p *Pool) Stats() []Stats {
// clientFor returns the memoised dns.Client for an upstream. // clientFor returns the memoised dns.Client for an upstream.
func (p *Pool) clientFor(u *Upstream) *dns.Client { func (p *Pool) clientFor(u *Upstream) *dns.Client {
p.mu.RLock() if c, ok := p.clients.Load(u); ok {
c, ok := p.clients[u] return c.(*dns.Client)
p.mu.RUnlock()
if ok {
return c
} }
var nc *dns.Client nc := &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second}
switch u.cfg.Proto { switch u.cfg.Proto {
case "tls": case "tls":
tlsCfg := &tls.Config{
ServerName: u.cfg.ServerName,
InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
}
nc = &dns.Client{ nc = &dns.Client{
Net: "tcp-tls", Net: "tcp-tls",
TLSConfig: tlsCfg, TLSConfig: &tls.Config{
Dialer: &net.Dialer{Timeout: 3 * time.Second}, ServerName: u.cfg.ServerName,
Timeout: 5 * time.Second, InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
},
Dialer: &net.Dialer{Timeout: 3 * time.Second},
Timeout: 5 * time.Second,
} }
case "tcp": case "tcp":
nc = &dns.Client{Net: "tcp", Timeout: 5 * time.Second} 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. // 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 // want restricts the query to the upstreams an ACL group named, by configured
// per-client upstream override. edns0Size is the client's advertised buffer, // or resolved address; an empty list means the whole pool. timeout bounds one
// used to decide TCP fallback on truncation. // attempt.
func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout time.Duration) (*dns.Msg, string, error) { func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) {
candidates := p.ups st := p.state.Load()
if len(tags) > 0 { candidates := st.all()
if tagged := p.byTag[strings.Join(tags, ",")]; len(tagged) > 0 { if len(want) > 0 {
candidates = tagged candidates = p.restrict(st, want)
}
} }
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, "", ErrNoUpstream return nil, "", ErrNoUpstream
} }
attempts := p.cfg.MaxAttempts chosen := p.pick(st, candidates, st.cfg.MaxAttempts)
chosen := selectFrom(candidates, p.cfg.Policy, attempts)
if len(chosen) == 0 { if len(chosen) == 0 {
return nil, "", ErrNoUpstream return nil, "", ErrNoUpstream
} }
if len(chosen) == 1 { if len(chosen) == 1 {
msg, up, err := p.exchangeOne(ctx, chosen[0], q, timeout) return p.exchangeOne(ctx, st.cfg, chosen[0], q, timeout)
return msg, up, err
} }
// Parallel: send to all at once, take the first answer that is not a // Parallel: send to all at once and take the first real answer. The
// transport failure. Cancellable so we do not leak requests to a slow // context is cancelled on return so we do not leak a request to a slow
// upstream after another one has answered. // upstream after another one has answered.
type res struct { type res struct {
msg *dns.Msg msg *dns.Msg
@@ -418,7 +457,7 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout
defer cancel() defer cancel()
for _, up := range chosen { for _, up := range chosen {
go func(up *Upstream) { 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} results <- res{msg: msg, up: addr, err: err}
}(up) }(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++ { for i := 0; i < len(chosen); i++ {
select { select {
case r := <-results: case r := <-results:
if r.err == nil && r.msg != nil && !isTransportFailure(r.msg.Rcode) { switch {
return r.msg, r.up, nil case r.err != nil:
}
if r.err != nil {
lastErr = r.err lastErr = r.err
} else if r.msg != nil { case r.msg != nil && r.msg.Rcode != dns.RcodeServerFailure:
lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode]) return r.msg, r.up, nil
default:
lastErr = fmt.Errorf("upstream %s returned SERVFAIL", r.up)
} }
case <-ctx.Done(): case <-ctx.Done():
return nil, "", ctx.Err() return nil, "", ctx.Err()
@@ -444,8 +483,29 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout
return nil, "", lastErr return nil, "", lastErr
} }
func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { // restrict resolves an ACL group's upstream list to upstreams, keeping the
// Filter out open breakers but keep everything if that empties the list. // 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)) open := make([]*Upstream, 0, len(ups))
for _, u := range ups { for _, u := range ups {
if !u.BreakerOpen() { if !u.BreakerOpen() {
@@ -458,11 +518,13 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
if maxAttempts <= 0 || maxAttempts > len(open) { if maxAttempts <= 0 || maxAttempts > len(open) {
maxAttempts = len(open) maxAttempts = len(open)
} }
switch policy { switch st.cfg.Policy {
case "failover": case "failover":
return open[:maxAttempts] return open[:maxAttempts]
case "random": 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] return open[:maxAttempts]
default: default:
return open 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 // exchangeOne sends q to a single upstream, retrying over TCP when the UDP
// answer comes back truncated (RFC 1035 §4.2.1). // 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 { if timeout <= 0 {
timeout = 2 * time.Second timeout = 2 * time.Second
} }
client := p.clientFor(up) client := p.clientFor(up)
msg := q.Copy() resp, rtt, err := client.ExchangeContext(ctx, q.Copy(), up.addr)
start := time.Now()
resp, rtt, err := client.ExchangeContext(ctx, msg, up.addr)
up.Success(rtt)
if err != nil { if err != nil {
up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D())
return nil, up.addr, err return nil, up.addr, err
} }
_ = start up.Success(rtt)
if !resp.Truncated { if !resp.Truncated {
return resp, up.addr, nil return resp, up.addr, nil
} }
// Retry over TCP to get the full answer. Some upstreams are configured // Retry over TCP for the full answer. An upstream that is UDP-only just
// UDP-only in which case this simply fails and we use the TC answer. // fails here and we return the truncated answer; the client retries over
// TCP itself.
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout} tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr) resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr)
if err2 == nil && resp2 != nil && !resp2.Truncated { if err2 != nil || resp2 == nil || resp2.Truncated {
up.Success(rtt2) if err2 == nil {
return resp2, up.addr, 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()) up.Success(rtt2)
// Return the truncated answer; the client will retry over TCP itself. return resp2, up.addr, nil
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
} }
// HealthCheck probes every upstream with a cheap query, refreshing health // HealthCheck probes every upstream with a cheap query, refreshing health
// state even when no client traffic is flowing. It returns the number of // state even when no client traffic is flowing. It returns the number of
// upstreams that answered. // upstreams that answered.
func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int { func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int {
st := p.state.Load()
probe := new(dns.Msg) probe := new(dns.Msg)
probe.SetQuestion(".", dns.TypeNS) probe.SetQuestion(".", dns.TypeNS)
probe.RecursionDesired = true probe.RecursionDesired = true
ok := 0 ok := 0
for _, up := range p.ups { for _, up := range st.all() {
if up.BreakerOpen() { if up.BreakerOpen() {
continue continue
} }
c := p.clientFor(up) c := p.clientFor(up)
_, rtt, err := c.ExchangeContext(ctx, probe, up.addr) _, rtt, err := c.ExchangeContext(ctx, probe, up.addr)
if err != nil { 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 continue
} }
up.Success(rtt) up.Success(rtt)
@@ -536,63 +589,47 @@ func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int {
return ok return ok
} }
func (p *Pool) breakerThresholdFor(u *Upstream) int { func thresholdFor(up *Upstream, def int) int {
if t := u.breakerThreshold(); t > 0 { if up.cfg.MaxFails > 0 {
return t return up.cfg.MaxFails
} }
return p.cfg.BreakerThreshold return def
} }
// Rebind applies a new upstream configuration in place, keeping the live // Rebind applies a new upstream configuration, keeping the live health state
// health state of upstreams whose address is unchanged. // 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) { func (p *Pool) Rebind(cfg config.Upstream) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
p.cfg = cfg old := p.state.Load()
old := make(map[string]*Upstream, len(p.ups)) next := buildState(cfg, old, true)
for _, u := range p.ups { p.state.Store(next)
old[u.cfg.Addr+"/"+u.cfg.Proto] = u // 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 _, u := range old.all() {
for _, s := range cfg.Servers { if !live[u] {
if u, ok := old[s.Addr+"/"+s.Proto]; ok { p.clients.Delete(u)
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)
} }
} }
p.resolveAllLocked()
} }
func (p *Pool) resolveAllLocked() { // String renders the pool for logs.
for _, b := range p.cfg.Bootstraps { func (p *Pool) String() string {
p.bootstrap[b.Name] = append([]string(nil), b.Addresses...) ups := p.All()
} addrs := make([]string, 0, len(ups))
for _, up := range p.ups { for _, u := range ups {
host, port, err := net.SplitHostPort(up.cfg.Addr) addrs = append(addrs, u.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
} }
return "pool[" + strings.Join(addrs, " ") + "]"
} }
func ptr[T any](v T) *T { return &v }
+14 -3
View File
@@ -1,3 +1,14 @@
<!doctype html><html><head><meta charset="utf-8"><title>fancy-dns</title></head> <!doctype html>
<body style="font:16px system-ui;padding:2rem"><h1>fancy-dns</h1> <html lang="en">
<p>Web console assets are not built in this binary. Run <code>make web</code> and rebuild.</p></body></html> <head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="./assets/index-BVuSgjZf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZ8p4VMZ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+918
View File
@@ -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)
}
}
+78 -116
View File
@@ -1,122 +1,84 @@
import { useState } from 'react' /**
import heroImg from './assets/hero.png' * App — routes, the shell and the theme bootstrap.
import reactLogo from './assets/react.svg' *
import viteLogo from './assets/vite.svg' * The daemon serves the console and the API from one origin, so a real
import './App.css' * BrowserRouter works as long as the Go handler falls back to index.html (it
* does). `useThemeInit` runs the stored theme onto `<html>` before the first
* paint; `ThemeToggle` inside AppShell keeps it in sync afterwards.
*/
function App() { import { useEffect } from 'react'
const [count, setCount] = useState(0) 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 `<html>` 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 ( return (
<> <Routes>
<section id="center"> <Route path="/" element={<Dashboard />} />
<div className="hero"> <Route path="/blocklists" element={<Blocklists />} />
<img src={heroImg} className="base" width="170" height="179" alt="" /> <Route path="/domains" element={<Domains />} />
<img src={reactLogo} className="framework" alt="React logo" /> <Route path="/clients" element={<Clients />} />
<img src={viteLogo} className="vite" alt="Vite logo" /> <Route path="/upstreams" element={<Upstreams />} />
</div> <Route path="/logs" element={<Logs />} />
<div> <Route path="/cache" element={<Cache />} />
<h1>Get started</h1> <Route path="/settings" element={<Settings />} />
<p> <Route path="*" element={<NotFound />} />
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> </Routes>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }
export default App export default function App() {
useThemeInit()
return (
<BrowserRouter>
<ToastProvider>
<Routes>
{/* Bare: no shell, no header, no nav. */}
<Route path="/auth/login" element={<Login />} />
<Route
path="*"
element={
<AppShell>
<AppRoutes />
</AppShell>
}
/>
</Routes>
</ToastProvider>
</BrowserRouter>
)
}
+3 -2
View File
@@ -27,7 +27,9 @@ export function useApi<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<
fnRef.current = fn fnRef.current = fn
useEffect(() => { 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 let live = true
setLoading(true) setLoading(true)
@@ -50,7 +52,6 @@ export function useApi<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<
return () => { return () => {
live = false live = false
controller.abort()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce]) }, [...deps, nonce])
+6 -6
View File
@@ -198,12 +198,6 @@
color: var(--fg); 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-width: thin;
scrollbar-color: var(--scroll-thumb) transparent; 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;
}
+11 -2
View File
@@ -417,6 +417,8 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
let backoff = RECONNECT_MIN_MS let backoff = RECONNECT_MIN_MS
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let attempt = 0 let attempt = 0
// One error report per outage streak: a flaky link must not toast every 15s.
let reported = false
const open = (): void => { const open = (): void => {
if (stopped) return if (stopped) return
@@ -447,11 +449,15 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
const message = res.ok const message = res.ok
? 'the live log stream is not supported by this browser' ? 'the live log stream is not supported by this browser'
: `the live log stream failed with HTTP ${res.status}` : `the live log stream failed with HTTP ${res.status}`
handlers.onError(message) if (!reported) {
reported = true
handlers.onError(message)
}
schedule() schedule()
return return
} }
reported = false
attempt = 0 attempt = 0
backoff = RECONNECT_MIN_MS backoff = RECONNECT_MIN_MS
handlers.onOpen() handlers.onOpen()
@@ -463,7 +469,10 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
if (stopped) return if (stopped) return
if (controller.signal.aborted) return if (controller.signal.aborted) return
const message = err instanceof Error ? err.message : 'the live log stream dropped' const message = err instanceof Error ? err.message : 'the live log stream dropped'
handlers.onError(message) if (!reported) {
reported = true
handlers.onError(message)
}
schedule() schedule()
} }
} }
+11 -1
View File
@@ -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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' 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(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
+25 -25
View File
@@ -10,12 +10,12 @@ import { useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react' import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api' import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types' import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi' import { useApi, useAsyncAction } from '../hooks/useApi'
import { useAutoRefresh } from '../../hooks/useAutoRefresh' import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { useDebounced } from '../../hooks/useDebounced' import { useDebounced } from '../hooks/useDebounced'
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format' import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format'
import { import {
Badge, Badge,
Button, Button,
@@ -29,21 +29,21 @@ import {
Table, Table,
useToast, useToast,
} from '../components/ui' } from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout' import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode> type Row = Record<string, ReactNode>
type ToastLike = { /**
success?: (message: string) => void * One line for every mutation on this page: the message plus the tone it
error?: (message: string) => void * earned. `fail` is the danger tone, `ok` the success one.
toast?: (message: string) => void */
push?: (message: string) => void function notify(
} toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { message: string,
const t = toast as ToastLike ): void {
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
fn?.call(t, message)
} }
const EMPTY_STATS: BlocklistStats = { const EMPTY_STATS: BlocklistStats = {
@@ -79,7 +79,7 @@ function truncate(value: string, max = 42): string {
} }
export default function Blocklists() { export default function Blocklists() {
const toast = useToast() const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), []) const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000) useAutoRefresh(refetch, 15000)
@@ -200,7 +200,7 @@ export default function Blocklists() {
), ),
refresh: ( refresh: (
<IconButton <IconButton
icon={<RefreshCw size={14} />} icon={RefreshCw}
label={`refresh ${item.name}`} label={`refresh ${item.name}`}
variant="ghost" variant="ghost"
disabled={busy} disabled={busy}
@@ -221,7 +221,7 @@ export default function Blocklists() {
description="Compiled rule sources, their freshness and their health." description="Compiled rule sources, their freshness and their health."
actions={ actions={
<Button <Button
icon={<RefreshCw size={14} />} icon={RefreshCw}
onClick={() => void onRefresh()} onClick={() => void onRefresh()}
loading={refresh.pending} loading={refresh.pending}
> >
@@ -231,24 +231,24 @@ export default function Blocklists() {
/> />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} /> <StatCard label="Total rules" value={formatNumber(stats.total)} icon={Layers} />
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} /> <StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
<StatCard <StatCard
label="Allow rules" label="Allow rules"
value={formatNumber(stats.allow)} value={formatNumber(stats.allow)}
icon={<ShieldCheck size={16} />} icon={ShieldCheck}
tone={stats.allow > 0 ? 'default' : 'warn'} tone={stats.allow > 0 ? 'default' : 'warn'}
/> />
<StatCard <StatCard
label="Duplicates" label="Duplicates"
value={formatNumber(stats.duplicates)} value={formatNumber(stats.duplicates)}
icon={<Split size={16} />} icon={Split}
tone={stats.duplicates > 0 ? 'warn' : 'ok'} tone={stats.duplicates > 0 ? 'warn' : 'ok'}
/> />
<StatCard <StatCard
label="Last compile" label="Last compile"
value={compiledAgo} value={compiledAgo}
icon={<Clock size={16} />} icon={Clock}
hint={stats.built_at ? undefined : 'no build recorded'} hint={stats.built_at ? undefined : 'no build recorded'}
/> />
</div> </div>
@@ -265,7 +265,7 @@ export default function Blocklists() {
placeholder="ads.example.com" placeholder="ads.example.com"
value={probe} value={probe}
onChange={(e) => setProbe(e.target.value)} onChange={(e) => setProbe(e.target.value)}
icon={<ShieldCheck size={14} />} icon={ShieldCheck}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
+393
View File
@@ -0,0 +1,393 @@
/**
* Cache — what the resolver keeps in memory between queries.
*
* Everything here is a read of `/api/cache` plus the top-50 ranking, refreshed
* on a slow 15s tick. The only mutation is the flush, which is destructive
* enough to sit behind a confirmation: it costs a cold cache, not a restart.
*/
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import {
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip as ChartTooltip,
} from 'recharts'
import { Database, FileClock, Gauge, HardDrive, Layers, RefreshCw, Timer } from 'lucide-react'
import { flushCache, getCache, getCacheTop } from '../lib/api'
import type { CachePersisted, CacheTopEntry } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { formatBytes, formatMs, formatNumber, formatRate, formatRelative } from '../lib/format'
import {
Badge,
Button,
Card,
CardBody,
ConfirmDialog,
EmptyState,
StatCard,
Table,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
const REFRESH_MS = 15000
const TOP_LIMIT = 50
/** Fixed hex so the donut reads on both themes, like the dashboard charts. */
const HITS = '#34d399'
const MISSES = '#f87171'
/** `persisted` gained fields over time; read defensively. */
type PersistedView = Partial<CachePersisted> & {
errors?: number
error?: string
last_error?: string
}
type PieSlice = { name: string; value: number; fill: string }
type TipProps = {
active?: boolean
payload?: { name?: string; value?: number | string; payload?: PieSlice }[]
}
function ChartTip({ active, payload }: TipProps) {
if (!active || payload === undefined || payload.length === 0) return null
return (
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
<ul className="space-y-0.5">
{payload.map((item) => (
<li key={item.name} className="flex items-center gap-2 text-slate-100">
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: item.payload?.fill }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
export default function Cache() {
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(() => getCache(), [])
const {
data: top,
error: topError,
loading: topLoading,
refetch: refetchTop,
} = useApi(() => getCacheTop(TOP_LIMIT), [])
useAutoRefresh(() => {
refetch()
refetchTop()
}, REFRESH_MS)
const [confirmFlush, setConfirmFlush] = useState(false)
const flush = useAsyncAction(async () => flushCache())
const hits = data?.hits ?? 0
const misses = data?.misses ?? 0
const lookups = hits + misses
const hitRate = data?.hit_rate ?? 0
const pie: PieSlice[] = useMemo(
() => [
{ name: 'hits', value: hits, fill: HITS },
{ name: 'misses', value: misses, fill: MISSES },
],
[hits, misses],
)
const persisted: PersistedView = (data?.persisted ?? {}) as PersistedView
const persistErrors = persisted.errors ?? 0
const persistMessage = persisted.last_error ?? persisted.error ?? ''
const entries: CacheTopEntry[] = useMemo(() => top?.items ?? [], [top])
const onFlush = async (): Promise<void> => {
const res = await flush.run()
if (res === null) {
notify('Could not flush the cache', flush.error ?? 'the server rejected the request')
return
}
setConfirmFlush(false)
notify(
'Cache flushed',
`${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`,
'ok',
)
refetch()
refetchTop()
}
if (error) return <PageError error={error} onRetry={refetch} />
const topColumns = [
{ key: 'name', header: 'Name', width: '48%' },
{ key: 'qtype', header: 'Type', width: '10%' },
{ key: 'count', header: 'Lookups', width: '14%', align: 'right' as const },
{ key: 'hits', header: 'Served from cache', width: '14%', align: 'right' as const },
{ key: 'last_seen', header: 'Last seen', width: '14%', align: 'right' as const },
]
const topRows: Row[] = entries.map((entry) => ({
__key: `${entry.name}-${entry.qtype}`,
name: (
<span className="block max-w-[420px] truncate font-mono text-xs" title={entry.name}>
{entry.name}
</span>
),
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
count: <span className="font-mono text-xs">{formatNumber(entry.count)}</span>,
hits: <span className="font-mono text-xs">{formatNumber(entry.hits)}</span>,
last_seen: <span className="text-xs text-fg-muted">{formatRelative(entry.last_seen)}</span>,
}))
return (
<div className="space-y-4">
<PageHeader
title="Cache"
description="Answer reuse, its lifetime, and what survives a restart."
actions={
<Button
variant="danger"
icon={RefreshCw}
onClick={() => setConfirmFlush(true)}
loading={flush.pending}
>
Flush cache
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<StatCard
label="Entries"
value={formatNumber(data?.entries ?? 0)}
icon={Database}
hint={data?.enabled === false ? 'cache disabled' : undefined}
loading={loading && data === null}
/>
<StatCard
label="Size"
value={formatBytes(data?.size ?? 0)}
icon={HardDrive}
hint={`${formatNumber(lookups)} lookups`}
/>
<StatCard
label="Shards"
value={formatNumber(data?.shards ?? 0)}
icon={Layers}
/>
<StatCard
label="Max TTL"
value={formatMs(data?.max_ttl_ms ?? 0)}
icon={Timer}
hint={`min ${formatMs(data?.min_ttl_ms ?? 0)}`}
/>
<StatCard
label="Serve stale"
value={formatMs(data?.serve_stale_ms ?? 0)}
icon={FileClock}
hint={data?.serve_stale_ms ? 'upstream timeouts tolerated' : 'never serve stale'}
tone={data?.serve_stale_ms ? 'default' : 'warn'}
/>
<StatCard
label="Evictions"
value={formatNumber(data?.evictions ?? 0)}
icon={Gauge}
hint={`${formatNumber(data?.stale_hits ?? 0)} stale hits`}
/>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card
title="Hit rate"
subtitle={`${formatRate(hitRate)} of ${formatNumber(lookups)} lookups served from memory`}
>
<CardBody>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<div className="relative h-48 w-48 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pie}
dataKey="value"
nameKey="name"
innerRadius="62%"
outerRadius="92%"
paddingAngle={1}
stroke="none"
isAnimationActive={false}
>
{pie.map((slice) => (
<Cell key={slice.name} fill={slice.fill} />
))}
</Pie>
<ChartTooltip content={<ChartTip />} />
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="text-center">
<p className="tabular text-xl font-semibold text-fg">{formatRate(hitRate, 0)}</p>
<p className="text-xs text-fg-muted">hit rate</p>
</div>
</div>
</div>
<ul className="w-full min-w-0 space-y-2 text-sm">
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ background: HITS }}
/>
<span className="text-fg-muted">Hits</span>
<span className="tabular ml-auto font-mono text-xs">
{formatNumber(hits)}
</span>
</li>
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ background: MISSES }}
/>
<span className="text-fg-muted">Misses</span>
<span className="tabular ml-auto font-mono text-xs">
{formatNumber(misses)}
</span>
</li>
<li className="flex items-center gap-2 border-t border-line pt-2 text-xs text-fg-muted">
<span>Negative TTL</span>
<span className="tabular ml-auto font-mono">
{formatMs(data?.negative_ttl_ms ?? 0)}
</span>
</li>
{lookups === 0 && (
<li className="text-xs text-fg-subtle">
Nothing has been looked up since the daemon started.
</li>
)}
</ul>
</div>
</CardBody>
</Card>
<Card
title="Persisted file"
subtitle={persisted.enabled ? 'Snapshots survive a restart' : 'Snapshots are turned off'}
actions={
<Badge tone={persisted.enabled ? 'ok' : 'muted'}>
{persisted.enabled ? 'enabled' : 'disabled'}
</Badge>
}
>
<CardBody>
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
<div className="col-span-2 min-w-0 sm:col-span-3">
<dt className="text-xs tracking-wide text-fg-muted uppercase">Path</dt>
<dd className="mt-0.5 truncate font-mono text-xs" title={persisted.path}>
{persisted.path || '—'}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Size</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatBytes(persisted.bytes ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Entries</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatNumber(persisted.entries ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Writes</dt>
<dd className="tabular mt-0.5 font-mono text-sm">
{formatNumber(persisted.writes ?? 0)}
</dd>
</div>
<div>
<dt className="text-xs tracking-wide text-fg-muted uppercase">Errors</dt>
<dd
className={
persistErrors > 0
? 'tabular mt-0.5 font-mono text-sm text-danger'
: 'tabular mt-0.5 font-mono text-sm'
}
>
{formatNumber(persistErrors)}
</dd>
</div>
<div className="col-span-2 sm:col-span-1">
<dt className="text-xs tracking-wide text-fg-muted uppercase">Last snapshot</dt>
<dd className="mt-0.5 text-sm">{formatRelative(persisted.last_snapshot)}</dd>
</div>
</dl>
{persistMessage !== '' && (
<p className="mt-3 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-xs text-danger">
{persistMessage}
</p>
)}
</CardBody>
</Card>
</div>
<Card
title="Most requested"
subtitle={`Top ${TOP_LIMIT} names by lookups`}
padded={false}
actions={<Badge tone="muted">auto-refresh every 15s</Badge>}
>
{topError !== null ? (
<CardBody>
<p className="text-sm text-danger">{topError}</p>
</CardBody>
) : (
<div className="overflow-x-auto">
<Table
columns={topColumns}
rows={topRows}
rowKey={(row) => String(row.__key)}
loading={topLoading && entries.length === 0}
empty={
<EmptyState
icon={Database}
title="The cache is empty"
description="Nothing has been cached yet, so there is no ranking to show."
/>
}
/>
</div>
)}
</Card>
<ConfirmDialog
open={confirmFlush}
onClose={() => 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}
/>
</div>
)
}
+23 -23
View File
@@ -10,10 +10,10 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react' import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { ApiError, getClients, putClients } from '../../lib/api' import { ApiError, getClients, putClients } from '../lib/api'
import type { ClientGroup, ClientMatch } from '../../lib/types' import type { ClientGroup, ClientMatch } from '../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi' import { useApi, useAsyncAction } from '../hooks/useApi'
import { fromLines, toLines } from '../../lib/format' import { fromLines, toLines } from '../lib/format'
import { import {
Badge, Badge,
Button, Button,
@@ -28,19 +28,19 @@ import {
Textarea, Textarea,
useToast, useToast,
} from '../components/ui' } from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout' import { PageError, PageHeader } from '../components/layout'
type ToastLike = { /**
success?: (message: string) => void * One line for every mutation on this page: the message plus the tone it
error?: (message: string) => void * earned. `fail` is the danger tone, `ok` the success one.
toast?: (message: string) => void */
push?: (message: string) => void function notify(
} toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { message: string,
const t = toast as ToastLike ): void {
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
fn?.call(t, message)
} }
const POLICIES = [ const POLICIES = [
@@ -173,11 +173,11 @@ function fieldLeaf(path: string): string {
} }
export default function Clients() { export default function Clients() {
const toast = useToast() const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getClients(), []) const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data]) const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
const [defaultPolicy, setDefaultPolicy] = useState<string>('') const [defaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null) const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT) const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
@@ -302,7 +302,7 @@ export default function Clients() {
title="Clients" title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy." description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
actions={ actions={
<Button icon={<Plus size={14} />} onClick={openNew}> <Button icon={Plus} onClick={openNew}>
Add group Add group
</Button> </Button>
} }
@@ -322,11 +322,11 @@ export default function Clients() {
{groups.length === 0 ? ( {groups.length === 0 ? (
<EmptyState <EmptyState
icon={<Users size={20} />} icon={Users}
title="No client groups" title="No client groups"
description="Without a group every client gets the default policy." description="Without a group every client gets the default policy."
action={ action={
<Button icon={<Plus size={14} />} onClick={openNew}> <Button icon={Plus} onClick={openNew}>
Add the first group Add the first group
</Button> </Button>
} }
@@ -362,10 +362,10 @@ export default function Clients() {
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} /> <IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} /> <IconButton icon={Copy} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
<IconButton <IconButton
icon={<Trash2 size={14} />} icon={Trash2}
label={`delete ${group.name}`} label={`delete ${group.name}`}
variant="ghost" variant="ghost"
onClick={() => setPendingDelete(i)} onClick={() => setPendingDelete(i)}
+11 -26
View File
@@ -23,11 +23,11 @@ import {
} from 'recharts' } from 'recharts'
import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react' import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react'
import { getDashboard } from '../../lib/api' import { getDashboard } from '../lib/api'
import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types' import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../lib/types'
import { useApi } from '../../hooks/useApi' import { useApi } from '../hooks/useApi'
import { useAutoRefresh } from '../../hooks/useAutoRefresh' import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange' import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange'
import { import {
formatClock, formatClock,
formatCompact, formatCompact,
@@ -36,7 +36,7 @@ import {
formatPercent, formatPercent,
formatRate, formatRate,
formatUptime, formatUptime,
} from '../../lib/format' } from '../lib/format'
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui' import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
import { HealthPill, PageError, PageHeader } from '../components/layout' import { HealthPill, PageError, PageHeader } from '../components/layout'
@@ -47,7 +47,6 @@ const QUERIES = '#38bdf8'
const CACHED = '#34d399' const CACHED = '#34d399'
const BLOCKED = '#f59e0b' const BLOCKED = '#f59e0b'
const ERRORS = '#f87171' const ERRORS = '#f87171'
const TIP_BG = '#0f172a'
/** The parts of the dashboard payload that older daemons may not send. */ /** The parts of the dashboard payload that older daemons may not send. */
type DashboardView = Partial<DashboardData> & { type DashboardView = Partial<DashboardData> & {
@@ -64,20 +63,6 @@ function view(data: DashboardData | null): DashboardView {
return (data ?? {}) as 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 = { type TipProps = {
active?: boolean active?: boolean
label?: number | string label?: number | string
@@ -222,7 +207,7 @@ export default function Dashboard() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
icon={<RefreshCw size={14} />} icon={RefreshCw}
onClick={refetch} onClick={refetch}
loading={loading} loading={loading}
> >
@@ -251,26 +236,26 @@ export default function Dashboard() {
label="Queries / s" label="Queries / s"
value={formatCompact(d.queries_per_sec ?? 0)} value={formatCompact(d.queries_per_sec ?? 0)}
hint={`${formatNumber(d.queries_total ?? 0)} total`} hint={`${formatNumber(d.queries_total ?? 0)} total`}
icon={<Activity size={16} />} icon={Activity}
/> />
<StatCard <StatCard
label="Cache hit rate" label="Cache hit rate"
value={formatRate(hitRate)} value={formatRate(hitRate)}
hint="higher is better" hint="higher is better"
icon={<Gauge size={16} />} icon={Gauge}
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'} tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
/> />
<StatCard <StatCard
label="Blocked" label="Blocked"
value={formatPercent(d.blocked_pct ?? 0)} value={formatPercent(d.blocked_pct ?? 0)}
hint={`${formatNumber(d.blocked_total ?? 0)} queries`} hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
icon={<Ban size={16} />} icon={Ban}
/> />
<StatCard <StatCard
label={latencyLabel} label={latencyLabel}
value={latencyValue != null ? formatMs(latencyValue) : '—'} value={latencyValue != null ? formatMs(latencyValue) : '—'}
hint={p99 != null ? 'from the server' : 'slowest upstream p95'} hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
icon={<Timer size={16} />} icon={Timer}
/> />
</div> </div>
+22 -21
View File
@@ -10,15 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Plus, Search, Trash2 } from 'lucide-react' import { Plus, Search, Trash2 } from 'lucide-react'
import { addDomain, deleteDomain, getDomains } from '../../lib/api' import { addDomain, deleteDomain, getDomains } from '../lib/api'
import type { AddDomainInput } from '../../lib/api' import type { AddDomainInput } from '../lib/api'
import type { DomainItem } from '../../lib/types' import type { DomainItem } from '../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi' import { useApi, useAsyncAction } from '../hooks/useApi'
import { useDebounced } from '../../hooks/useDebounced' import { useDebounced } from '../hooks/useDebounced'
import { formatNumber, formatRelative, toLines } from '../../lib/format' import { formatNumber, formatRelative, toLines } from '../lib/format'
import { import {
Badge, Badge,
Button, Button,
Card,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
EmptyState, EmptyState,
@@ -30,21 +31,21 @@ import {
Textarea, Textarea,
useToast, useToast,
} from '../components/ui' } from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout' import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode> type Row = Record<string, ReactNode>
type ToastLike = { /**
success?: (message: string) => void * One line for every mutation on this page: the message plus the tone it
error?: (message: string) => void * earned. `fail` is the danger tone, `ok` the success one.
toast?: (message: string) => void */
push?: (message: string) => void function notify(
} toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { message: string,
const t = toast as ToastLike ): void {
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
fn?.call(t, message)
} }
const PER_PAGE = 50 const PER_PAGE = 50
@@ -88,7 +89,7 @@ type AddForm = {
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false } const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
export default function Domains() { export default function Domains() {
const toast = useToast() const { toast } = useToast()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [action, setAction] = useState('') const [action, setAction] = useState('')
const [list, setList] = useState('') const [list, setList] = useState('')
@@ -220,7 +221,7 @@ export default function Domains() {
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>, updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
delete: ( delete: (
<IconButton <IconButton
icon={<Trash2 size={14} />} icon={Trash2}
label={`delete ${item.name}`} label={`delete ${item.name}`}
variant="ghost" variant="ghost"
onClick={() => setPendingDelete(item)} onClick={() => setPendingDelete(item)}
@@ -234,7 +235,7 @@ export default function Domains() {
title="Domains" title="Domains"
description="Inline allow and block rules, checked before the downloaded lists." description="Inline allow and block rules, checked before the downloaded lists."
actions={ actions={
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}> <Button icon={Plus} onClick={() => setAddOpen(true)}>
Add rule Add rule
</Button> </Button>
} }
@@ -245,7 +246,7 @@ export default function Domains() {
<Input <Input
label="Search" label="Search"
placeholder="ads.example.com" placeholder="ads.example.com"
icon={<Search size={14} />} icon={Search}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
autoComplete="off" autoComplete="off"
+182
View File
@@ -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<string | null>(null)
const [apiKey, setApiKey] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(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<HTMLFormElement>): Promise<void> => {
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 (
<div className="flex min-h-svh items-center justify-center bg-canvas px-4 py-10">
<div className="w-full max-w-sm">
<div className="mb-5 flex items-center justify-center gap-2">
<span className="grid size-8 place-items-center rounded-lg bg-accent-soft text-accent">
<Globe size={17} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="text-base font-semibold tracking-tight text-fg">fancy-dns</span>
</div>
<Card
title="Sign in"
subtitle={
mode === null
? 'Checking the authentication mode…'
: passwordMode
? 'This node uses username and password.'
: 'This node uses an API key.'
}
>
<CardBody>
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
{passwordMode ? (
<>
<Input
label="Username"
icon={ShieldCheck}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
<Input
label="Password"
type="password"
icon={KeyRound}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</>
) : (
<Input
label="API key"
type="password"
icon={KeyRound}
placeholder="paste the node's API key"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="current-password"
spellCheck={false}
autoFocus
required
/>
)}
{error !== null && (
<p
role="alert"
className="rounded-lg border border-danger/40 bg-danger-soft px-3 py-2 text-xs text-danger"
>
{error}
</p>
)}
<Button
type="submit"
variant="primary"
size="lg"
icon={pending ? undefined : LogIn}
loading={pending}
className="w-full justify-center"
>
{pending ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardBody>
</Card>
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Scripts can skip this page: send the same credential as an{' '}
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
<span className="font-mono text-fg-muted">
curl -H &quot;X-API-Key: $KEY&quot; http://node:8080/api/dashboard
</span>
.
</span>
</p>
{pending && (
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
<Spinner size="sm" />
waiting for the node
</p>
)}
</div>
</div>
)
}
+431
View File
@@ -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<string, ReactNode>
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<string | null>(null)
const [liveRows, setLiveRows] = useState<LogItem[]>([])
const [stats, setStats] = useState<LiveStats | null>(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<LogFilters>(
() => ({
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<Filter>({ 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<LogItem[]>(
() => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS),
[liveRows, items],
)
const onClear = async (): Promise<void> => {
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 <PageError error={error} onRetry={refetch} />
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: <span className="tabular font-mono text-xs text-fg-muted">{formatClock(entry.ts)}</span>,
client: (
<span className="block max-w-[180px] truncate font-mono text-xs">
{entry.client || '—'}
</span>
),
group: entry.client_group ? (
<Badge tone="muted">{entry.client_group}</Badge>
) : (
'—'
),
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={entry.name}>
{entry.name}
</span>
),
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
rcode: <span className="font-mono text-xs text-fg-muted">{formatRcode(entry.rcode)}</span>,
action: <Badge tone={actionTone(entry.action)}>{entry.action || 'unknown'}</Badge>,
duration: <span className="font-mono text-xs">{formatMs(entry.duration_ms)}</span>,
upstream: (
<span className="block max-w-[160px] truncate font-mono text-xs text-fg-muted">
{entry.upstream || '—'}
</span>
),
list: (
<span className="block max-w-[200px] truncate text-xs text-fg-muted" title={entry.list}>
{entry.list || '—'}
</span>
),
}))
const qps = stats?.qps ?? 0
const blockedPct = stats?.blocked_pct ?? 0
const hitRate = stats?.cache_hit_rate ?? 0
return (
<div className="space-y-4">
<PageHeader
title="Logs"
description="Every query the daemon answered, with the rule that decided it."
actions={
<div className="flex flex-wrap items-center gap-2">
<Badge
tone={live ? (connected ? 'ok' : 'danger') : 'muted'}
className="gap-1.5"
>
<span
aria-hidden="true"
className={cn(
'size-1.5 rounded-full',
live ? (connected ? 'animate-pulse-soft bg-ok' : 'animate-pulse-soft bg-danger') : 'bg-fg-subtle',
)}
/>
{live ? (connected ? 'LIVE' : 'DISCONNECTED') : 'PAUSED'}
</Badge>
<Switch
checked={live}
onChange={toggleLive}
label="Live tail"
description="Stream new entries over SSE"
/>
<Button
variant="outline"
icon={Eraser}
onClick={() => setConfirmClear(true)}
loading={clear.pending}
>
Clear
</Button>
</div>
}
/>
{live && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs shadow-panel">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<Signal size={13} aria-hidden="true" />
<span className="tabular font-mono text-fg">{formatCompact(qps)}</span> qps
</span>
<span className="text-fg-muted">
<span className="tabular font-mono text-fg">{formatPercent(blockedPct)}</span>{' '}
blocked
</span>
<span className="text-fg-muted">
<span className="tabular font-mono text-fg">{formatRate(hitRate)}</span> cache hits
</span>
<span className="text-fg-subtle">sampled by the daemon every few seconds</span>
{streamError !== null && (
<span className="text-danger" title={streamError}>
<WifiOff size={13} className="mr-1 inline align-[-2px]" aria-hidden="true" />
{streamError}
</span>
)}
</div>
)}
<Card title="Filters" subtitle="Free text covers name, list, upstream and client.">
<CardBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
label="Search"
icon={Search}
placeholder="example.com, ads, 10.0.0.0/8"
value={search}
onChange={(event) => {
setSearch(event.target.value)
applyFilter()
}}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Action"
options={ACTION_OPTIONS}
value={action}
onChange={(event) => {
setAction(event.target.value)
applyFilter()
}}
/>
<Input
label="Client"
placeholder="address or group name"
value={client}
onChange={(event) => {
setClient(event.target.value)
applyFilter()
}}
autoComplete="off"
spellCheck={false}
/>
<div className="flex items-end pb-1">
<Switch
checked={blockedOnly}
onChange={(next) => {
setBlockedOnly(next)
applyFilter()
}}
label="Blocked only"
description="Hide allowed and cached answers"
/>
</div>
</div>
</CardBody>
</Card>
<Card
title="Query log"
subtitle={`${rows.length} of ${formatNumber(total)} matching entries`}
padded={false}
actions={
rows.length >= MAX_ROWS ? (
<Badge tone="warn">capped at {MAX_ROWS} rows</Badge>
) : (
<Badge tone="muted">
<Radio size={11} aria-hidden="true" />
{live ? 'streaming' : 'paused'}
</Badge>
)
}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={tableRows}
rowKey={(row) => String(row.__key)}
loading={loading && items.length === 0 && liveRows.length === 0}
maxHeight={640}
empty="No query matched these filters."
/>
</div>
</Card>
<Pagination
page={page}
perPage={PER_PAGE}
total={total}
onPage={setPage}
/>
<ConfirmDialog
open={confirmClear}
onClose={() => 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}
/>
</div>
)
}
+53
View File
@@ -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 (
<div className="space-y-4">
<EmptyState
icon={Compass}
title="404 — that page does not exist"
description="The console has no route for this address. It may have been renamed, or the link may be stale."
action={
<Link to="/">
<Button variant="primary" icon={LayoutDashboard}>
Back to the dashboard
</Button>
</Link>
}
/>
<Card title="Available pages" subtitle="The console ships these routes">
<CardBody>
<ul className="grid grid-cols-1 gap-1.5 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
['/', 'Dashboard'],
['/blocklists', 'Blocklists'],
['/domains', 'Domains'],
['/clients', 'Clients'],
['/upstreams', 'Upstreams'],
['/logs', 'Logs'],
['/cache', 'Cache'],
['/settings', 'Settings'],
].map(([to, label]) => (
<li key={to}>
<Link
to={to}
className="flex items-baseline gap-2 rounded-lg px-2 py-1.5 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<span className="font-mono text-xs text-fg-subtle">{to}</span>
<span className="truncate">{label}</span>
</Link>
</li>
))}
</ul>
</CardBody>
</Card>
</div>
)
}
+471
View File
@@ -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 (
<ul className="space-y-1.5">
{list.map((issue, index) => (
<li key={`${issue.field}-${index}`} className="flex min-w-0 items-start gap-2 text-xs">
{tone === 'danger' ? (
<TriangleAlert size={13} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
) : (
<Info size={13} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
)}
<span className="min-w-0">
{issue.field !== '' && (
<span className="mr-1.5 font-mono text-fg-subtle">{issue.field}</span>
)}
<span className={tone === 'danger' ? 'text-danger' : 'text-fg-muted'}>
{issue.message}
</span>
</span>
</li>
))}
</ul>
)
}
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<HTMLDivElement | null>(null)
const [report, setReport] = useState<Report | null>(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<HTMLTextAreaElement>): void => {
const element = gutter.current
if (element !== null) element.scrollTop = event.currentTarget.scrollTop
}
const onValidate = async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 <PageError error={error} onRetry={refetch} />
if (loading && data === null) {
return (
<div className="space-y-4">
<PageHeader title="Settings" description="The configuration the daemon is running." />
<Card>
<CardBody>
<LoadingBlock rows={8} />
</CardBody>
</Card>
</div>
)
}
const effective = data?.effective_yaml ?? ''
const defaults = data?.defaults_yaml ?? ''
return (
<div className="space-y-4">
<PageHeader
title="Settings"
description="The configuration the daemon is running, and the file behind it."
actions={
<div className="flex flex-wrap items-center gap-2">
<Badge tone={writable ? 'ok' : 'warn'}>{writable ? 'writable' : 'read-only'}</Badge>
<Button
variant="outline"
icon={RefreshCw}
onClick={() => setConfirmReload(true)}
loading={reload.pending}
disabled={data?.reload_available === false}
>
Reload from disk
</Button>
</div>
}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-fg-muted">
<span className="inline-flex min-w-0 items-center gap-1.5">
<FileCode2 size={12} aria-hidden="true" />
<span className="truncate font-mono" title={data?.path}>
{data?.path || 'unknown path'}
</span>
</span>
<span>version {data?.version || 'unknown'}</span>
{data?.reload_available === false && (
<span>the daemon cannot reload this file at runtime</span>
)}
</div>
{data?.last_error ? (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-danger/40 bg-danger-soft px-4 py-3"
>
<ShieldAlert size={18} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">The daemon reported a config error</p>
<p className="mt-0.5 font-mono text-xs break-words text-danger">{data.last_error}</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card
title="Configuration file"
subtitle={`${lineCount} lines, as stored on disk`}
padded={false}
actions={<Badge tone={dirty ? 'warn' : 'muted'}>{dirty ? 'modified' : 'in sync'}</Badge>}
>
<div className="flex min-w-0 items-stretch">
<div
ref={gutter}
aria-hidden="true"
className="max-h-[520px] w-11 shrink-0 overflow-hidden border-r border-line bg-surface-2 py-2 pr-2 text-right font-mono text-xs leading-[1.625] text-fg-subtle select-none"
>
{gutterLines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
<Textarea
monospace
value={value}
onChange={(event) => setDraft({ version, text: event.currentTarget.value })}
onScroll={onScroll}
rows={26}
spellCheck={false}
wrap="off"
aria-label="Configuration YAML"
className="h-[520px] min-h-[520px] rounded-none border-0 bg-transparent"
/>
</div>
</Card>
<div className="flex min-w-0 flex-col gap-3">
<Card
title="Validate and save"
subtitle="The daemon parses the exact text on the left."
actions={
<div className="flex items-center gap-2">
<Button
icon={CheckCircle2}
onClick={() => void onValidate()}
loading={validate.pending}
>
Validate
</Button>
<Button
variant="primary"
icon={Save}
onClick={() => void onSave()}
loading={save.pending}
disabled={!writable || !dirty}
>
Save
</Button>
</div>
}
>
<CardBody>
{!writable && (
<p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-fg">
<Ban size={14} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
<span>
This daemon reports the config file as read-only, so Save is disabled. Edit{' '}
<span className="font-mono">{data?.path || 'the file'}</span> on the host and
use Reload from disk.
</span>
</p>
)}
{writable && !dirty && (
<p className="text-xs text-fg-subtle">
Nothing to save: the editor matches the file on disk.
</p>
)}
{validate.pending && (
<p className="flex items-center gap-2 text-xs text-fg-muted">
<Spinner size="sm" />
validating {lineCount} lines…
</p>
)}
{validate.error !== null && <p className="text-xs text-danger">{validate.error}</p>}
</CardBody>
</Card>
<Card
title="Validation report"
subtitle={
report === null
? 'Nothing validated yet'
: `${report.errors.length} ${report.errors.length === 1 ? 'error' : 'errors'}, ${report.warnings.length} ${report.warnings.length === 1 ? 'warning' : 'warnings'}`
}
actions={
report === null ? null : (
<Badge tone={report.errors.length === 0 ? 'ok' : 'danger'}>
{report.errors.length === 0 ? 'valid' : 'invalid'}
</Badge>
)
}
>
<CardBody>
{report === null ? (
<p className="text-xs text-fg-subtle">
Press Validate to parse the current text against the daemon&apos;s schema.
</p>
) : report.errors.length === 0 && report.warnings.length === 0 ? (
<p className="text-xs text-ok">No errors and no warnings.</p>
) : (
<div className="space-y-3">
{report.errors.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-semibold text-danger">Errors</p>
<IssueList issues={report.errors} tone="danger" />
</div>
)}
{report.warnings.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-semibold text-warn">Warnings</p>
<IssueList issues={report.warnings} tone="warn" />
</div>
)}
</div>
)}
</CardBody>
</Card>
</div>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card
title="Effective configuration"
subtitle="Your file with every default filled in"
actions={<CopyButton value={effective} label="Copy effective config" />}
>
<CardBody>
<details>
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
<FileCode2 size={14} aria-hidden="true" />
Show the effective configuration
<span className="text-xs text-fg-subtle">
{effective === '' ? '' : `${effective.split('\n').length} lines`}
</span>
</summary>
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
{effective || 'The server sent no effective configuration.'}
</pre>
</details>
</CardBody>
</Card>
<Card
title="Defaults"
subtitle="What the daemon falls back to for anything you leave out"
actions={<CopyButton value={defaults} label="Copy defaults" />}
>
<CardBody>
<details>
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
<Info size={14} aria-hidden="true" />
Show defaults
<span className="text-xs text-fg-subtle">read-only</span>
</summary>
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
{defaults || 'The server sent no defaults.'}
</pre>
</details>
</CardBody>
</Card>
</div>
<Card title="Quick actions" subtitle="The two things operators reach for from here">
<CardBody>
<div className="flex flex-wrap items-center gap-2">
<Button
icon={Database}
variant="outline"
onClick={() => void onFlush()}
loading={flush.pending}
>
Flush cache
</Button>
<Button
icon={RefreshCw}
variant="outline"
onClick={() => void onRefreshLists()}
loading={refreshLists.pending}
>
Refresh blocklists
</Button>
<span className="text-xs text-fg-subtle">
Both act on the running daemon; neither writes the config file.
</span>
</div>
</CardBody>
</Card>
<ConfirmDialog
open={confirmReload}
onClose={() => setConfirmReload(false)}
onConfirm={() => void onReload()}
title="Reload the configuration from disk?"
message={
<>
The daemon re-reads <span className="font-mono">{data?.path || 'its config'}</span>{' '}
and applies it to the running process. In-flight queries finish on the old
configuration; the next query uses the new one.
</>
}
confirmLabel="Reload"
pending={reload.pending}
/>
</div>
)
}
+32 -27
View File
@@ -20,10 +20,10 @@ import {
Trash2, Trash2,
} from 'lucide-react' } from 'lucide-react'
import { getUpstreams, putUpstreams } from '../../lib/api' import { getUpstreams, putUpstreams } from '../lib/api'
import type { UpstreamBootstrap, UpstreamServer } from '../../lib/types' import type { UpstreamBootstrap, UpstreamHealth, UpstreamServer } from '../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi' import { useApi, useAsyncAction } from '../hooks/useApi'
import { formatLatency, formatMs, formatNumber, formatRate } from '../../lib/format' import { formatLatency, formatMs, formatNumber, formatRate } from '../lib/format'
import { import {
Badge, Badge,
Button, Button,
@@ -40,21 +40,21 @@ import {
Textarea, Textarea,
useToast, useToast,
} from '../components/ui' } from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout' import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode> type Row = Record<string, ReactNode>
type ToastLike = { /**
success?: (message: string) => void * One line for every mutation on this page: the message plus the tone it
error?: (message: string) => void * earned. `fail` is the danger tone, `ok` the success one.
toast?: (message: string) => void */
push?: (message: string) => void function notify(
} toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { message: string,
const t = toast as ToastLike ): void {
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
fn?.call(t, message)
} }
const POLICIES = [ const POLICIES = [
@@ -167,7 +167,7 @@ function splitList(value: string): string[] {
} }
export default function Upstreams() { export default function Upstreams() {
const toast = useToast() const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getUpstreams(), []) const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
const [draft, setDraft] = useState<Draft | null>(null) const [draft, setDraft] = useState<Draft | null>(null)
@@ -210,10 +210,15 @@ export default function Upstreams() {
insecure_skip_verify: s.insecure_skip_verify, insecure_skip_verify: s.insecure_skip_verify,
weight: s.weight, weight: s.weight,
comment: s.comment, comment: s.comment,
health: s.health, health: s.health as UpstreamHealth,
latency_ms: s.latency_ms, latency_ms: s.latency_ms,
fail_rate: s.fail_rate, fail_rate: s.fail_rate,
breaker_open: s.breaker_open, breaker_open: s.breaker_open,
// Runtime counters are not part of a write: the server owns them and
// ignores these fields on PUT /upstreams.
queries: 0,
avg_ms: 0,
p95_ms: 0,
})), })),
bootstraps: working.bootstraps.map((b) => ({ bootstraps: working.bootstraps.map((b) => ({
name: b.name.trim(), name: b.name.trim(),
@@ -314,10 +319,10 @@ export default function Upstreams() {
comment: <span className="block max-w-[160px] truncate text-xs text-slate-500">{s.comment}</span>, comment: <span className="block max-w-[160px] truncate text-xs text-slate-500">{s.comment}</span>,
order: ( order: (
<span className="flex justify-end gap-0.5"> <span className="flex justify-end gap-0.5">
<IconButton icon={<ArrowUp size={13} />} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} /> <IconButton icon={ArrowUp} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} />
<IconButton icon={<ArrowDown size={13} />} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} /> <IconButton icon={ArrowDown} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} />
<IconButton icon={<Pencil size={13} />} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} /> <IconButton icon={Pencil} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} />
<IconButton icon={<Trash2 size={13} />} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} /> <IconButton icon={Trash2} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} />
</span> </span>
), ),
})) }))
@@ -331,13 +336,13 @@ export default function Upstreams() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
icon={<RotateCw size={14} />} icon={RotateCw}
onClick={() => setDraft(null)} onClick={() => setDraft(null)}
disabled={!dirty} disabled={!dirty}
> >
Discard Discard
</Button> </Button>
<Button icon={<Save size={14} />} onClick={() => void commit()} loading={save.pending} disabled={!dirty}> <Button icon={Save} onClick={() => void commit()} loading={save.pending} disabled={!dirty}>
Save Save
</Button> </Button>
</div> </div>
@@ -386,7 +391,7 @@ export default function Upstreams() {
actions={ actions={
<Button <Button
size="sm" size="sm"
icon={<Plus size={14} />} icon={Plus}
onClick={() => { onClick={() => {
setEditing(EMPTY_SERVER) setEditing(EMPTY_SERVER)
setEditorOpen(true) setEditorOpen(true)
@@ -414,7 +419,7 @@ export default function Upstreams() {
actions={ actions={
<Button <Button
size="sm" size="sm"
icon={<Plus size={14} />} icon={Plus}
onClick={() => onClick={() =>
setDraft({ setDraft({
...working, ...working,
@@ -428,7 +433,7 @@ export default function Upstreams() {
> >
{working.bootstraps.length === 0 ? ( {working.bootstraps.length === 0 ? (
<EmptyState <EmptyState
icon={<Gauge size={20} />} icon={Gauge}
title="No bootstrap resolvers" title="No bootstrap resolvers"
description="Without them, an upstream address must already be an IP." description="Without them, an upstream address must already be an IP."
/> />
@@ -457,7 +462,7 @@ export default function Upstreams() {
placeholder="24h" placeholder="24h"
/> />
<IconButton <IconButton
icon={<Trash2 size={14} />} icon={Trash2}
label={`remove bootstrap ${b.name}`} label={`remove bootstrap ${b.name}`}
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
+7 -1
View File
@@ -20,7 +20,13 @@ export default defineConfig({
}, },
}, },
build: { build: {
outDir: 'dist', // The Go binary embeds the console via //go:embed all:dist in
// internal/web/web.go, so the build has to land there rather than in
// web/dist: a bundle nobody embeds is a console nobody can load.
outDir: '../internal/web/dist',
emptyOutDir: true,
sourcemap: false, sourcemap: false,
// Hashed asset names let the server cache them immutably.
assetsDir: 'assets',
}, },
}) })