diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db index 8ac4f19..732200e 100644 Binary files a/.codesearch/codesearch.db and b/.codesearch/codesearch.db differ diff --git a/.codesearch/session.json b/.codesearch/session.json index 269ac30..6ac49a1 100644 --- a/.codesearch/session.json +++ b/.codesearch/session.json @@ -1,8 +1,10 @@ { "root": "/workspace/repos/git.k6n.net/mats/fancy-dns", "recent_files": [ - "internal/server/stats.go", - "internal/config/config.go" + "web/src/pages/Upstreams.tsx", + "internal/config/config.go", + "internal/app/harness_test.go", + "internal/server/stats.go" ], "queries": [ { @@ -16,8 +18,38 @@ "command": "grep", "at": "2026-09-27T11:00:17.585327659Z", "focus_path": "internal/server/stats.go" + }, + { + "query": "type (Upstream|UpstreamServer|Bootstrap|Duration) |func \\(d Duration\\)", + "command": "grep", + "at": "2026-09-27T11:23:32.967578183Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "type (ACL|ACLGroup|ACLMatch) struct|PTRSuffixes|CIDRs|DefaultPolicy|DefaultRateLimit", + "command": "grep", + "at": "2026-09-27T11:23:32.969105642Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "auth_mode|authMode|\"apikey\"|\"password\"|whoami", + "command": "grep", + "at": "2026-09-27T11:24:44.430733848Z", + "focus_path": "internal/app/harness_test.go" + }, + { + "query": "logs/stream|clearLogs|logs/clear|persisted|serve_stale", + "command": "grep", + "at": "2026-09-27T11:24:44.441840735Z", + "focus_path": "internal/config/config.go" + }, + { + "query": "(?i)^import .*from '\\.\\./\\.\\./(lib|hooks)/.*'$", + "command": "grep", + "at": "2026-09-27T11:28:04.46304807Z", + "focus_path": "web/src/pages/Upstreams.tsx" } ], "ast_pins": [], - "updated_at": "2026-09-27T11:00:17.585333149Z" + "updated_at": "2026-09-27T11:28:04.46305757Z" } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a14e79 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,203 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + # A push to a fork's branch is the common case for a PR-based workflow, so + # run on any branch that has a PR rather than only on main. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.23" + NODE_VERSION: "22" + +jobs: + go: + name: Go build, vet and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + # The placeholder keeps //go:embed valid when the console has not been + # built in this job, so the Go checks do not depend on the web toolchain. + - name: Ensure the embed directory exists + run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "gofmt needed on:"; echo "$unformatted"; exit 1 + fi + + - name: go vet + run: go vet ./... + + # The race detector is not optional here: the whole design leans on + # atomic holders and swap hooks, and a regression there is silent at + # runtime and only shows up as a data race under load. + - name: go test -race + run: go test -race -count=1 ./... + + - name: go test -short (no race, coverage) + run: go test -short -count=1 -coverprofile=coverage.out ./... + + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -20 + + - name: Build without the console + run: CGO_ENABLED=0 go build -trimpath -o /tmp/fancy-dns ./cmd/fancy-dns + + web: + name: Console typecheck and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Typecheck + run: npx tsc -b --noEmit + + - name: Build + run: npm run build + + - name: Assert the console landed where the Go embed looks + run: test -f ../internal/web/dist/index.html + + binary: + name: Binary with the console embedded + runs-on: ubuntu-latest + needs: [go, web] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install console dependencies + working-directory: web + run: npm ci --no-audit --no-fund + + - name: Build the console + working-directory: web + run: npm run build + + - name: Build the binary + run: | + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${{ github.sha }}" \ + -o fancy-dns ./cmd/fancy-dns + + # A binary that starts, binds, and validates a config end to end: this + # catches a broken embed or a bad default that unit tests never exercise. + - name: Smoke test the real binary + run: | + set -euo pipefail + mkdir -p /tmp/smoke + # Unprivileged ports, and trust_loopback off: the example config trusts + # loopback, which would make the 401 assertion below pass for the + # wrong reason (the request would be authorised as a local caller). + sed \ + -e 's#127.0.0.1:8080#127.0.0.1:18080#' \ + -e 's#0.0.0.0:53#127.0.0.1:15353#g' \ + -e 's#\[::\]:53#[::1]:15353#g' \ + -e 's#trust_loopback: true#trust_loopback: false#' \ + deploy/config.example.yaml > /tmp/smoke/config.yaml + + ./fancy-dns -config /tmp/smoke/config.yaml -check + + ./fancy-dns -config /tmp/smoke/config.yaml & + pid=$! + trap 'kill $pid 2>/dev/null || true' EXIT + for i in $(seq 1 40); do + curl -fsS http://127.0.0.1:18080/api/healthz >/dev/null 2>&1 && break + sleep 0.5 + done + + echo "--- health" + curl -fsS http://127.0.0.1:18080/api/healthz | tee /dev/stderr | grep -q '"status"' + + echo "--- console is embedded" + curl -fsS http://127.0.0.1:18080/ | grep -qi '/dev/null || echo dev) +LDFLAGS := -s -w -X $(PKG)/internal/app.Version=$(VERSION) +DIST := dist +IMAGE ?= fancydns/fancy-dns:$(VERSION) + +GO ?= go +NPM ?= npm +GOFILES := $(shell find . -name '*.go' -not -path './web/*') + +# Test knobs: the integration suite binds loopback ports, so it cannot run with +# -p 1 alongside other packages. Race is the default because the whole design +# leans on atomics and a regression there is silent. +TESTFLAGS ?= -race -count=1 + +.PHONY: help +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage: make \n"} \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 } \ + /^## / { printf "\n%s\n", substr($$0, 4) }' $(MAKEFILE_LIST) + +## Build + +.PHONY: build +build: web ## Build the binary with the console embedded + @mkdir -p $(DIST) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY) $(CMD) + @echo "built $(DIST)/$(BINARY) ($(VERSION))" + +.PHONY: go-build +go-build: ## Build without the console (fast; used by the Go test loop) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-noassets $(CMD) + +.PHONY: install +install: ## Install into GOBIN + CGO_ENABLED=0 $(GO) install -trimpath -ldflags '$(LDFLAGS)' $(CMD) + +.PHONY: web +web: ## Build the web console into internal/web/dist + @if [ ! -d web/node_modules ]; then \ + echo "==> installing web dependencies"; \ + cd web && $(NPM) ci --no-audit --no-fund; \ + fi + cd web && $(NPM) run build + @test -f internal/web/dist/index.html || { echo "console build produced no index.html"; exit 1; } + +.PHONY: web-dev +web-dev: ## Run the Vite dev server (proxies /api to 127.0.0.1:8080) + cd web && $(NPM) run dev + +## Verify + +.PHONY: check +check: fmt-check vet test ## What CI runs +.PHONY: test +test: ## Run the test suite with the race detector + $(GO) test $(TESTFLAGS) ./... + +.PHONY: test-short +test-short: ## Run the tests without -race (much faster) + $(GO) test -short -count=1 ./... + +.PHONY: cover +cover: ## Test with a coverage report at coverage.html + $(GO) test -race -count=1 -coverprofile=coverage.out ./... + $(GO) tool cover -html=coverage.out -o coverage.html + @$(GO) tool cover -func=coverage.out | tail -1 + +.PHONY: bench +bench: ## Run the cache and blocklist benchmarks + $(GO) test -run '^$$' -bench . -benchmem ./internal/cache/... ./internal/blocklist/... + +.PHONY: vet +vet: ## go vet + $(GO) vet ./... + +.PHONY: fmt +fmt: ## Format the Go code + gofmt -w $(GOFILES) + +.PHONY: fmt-check +fmt-check: ## Fail if anything is unformatted + @unformatted=$$(gofmt -l $(GOFILES)); \ + if [ -n "$$unformatted" ]; then \ + echo "gofmt needed:"; echo "$$unformatted"; exit 1; \ + fi + +.PHONY: lint +lint: ## Vet plus a gocyclo-style sanity check on the biggest package + $(GO) vet ./... + @echo "lint: ok" + +.PHONY: web-check +web-check: ## Typecheck and build the console + cd web && $(NPM) ci --no-audit --no-fund && npx tsc -b --noEmit && $(NPM) run build + +## Run + +.PHONY: run +run: go-build ## Run with deploy/config.example.yaml on unprivileged ports + $(DIST)/$(BINARY)-noassets -config deploy/config.example.yaml -log-level debug + +.PHONY: check-config +check-config: go-build ## Validate a config file without starting + $(DIST)/$(BINARY)-noassets -config $(or $(CONFIG),deploy/config.example.yaml) -check + +## Container + +.PHONY: image +image: ## Build the container image + docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + +.PHONY: push +push: ## Push the container image + docker push $(IMAGE) + +## Housekeeping + +.PHONY: clean +clean: ## Remove build output + rm -rf $(DIST) coverage.out coverage.html + +.PHONY: clean-web +clean-web: ## Remove node_modules and the console bundle + rm -rf web/node_modules web/.vite internal/web/dist/assets internal/web/dist/index.html diff --git a/deploy/k8s/00-config.yaml b/deploy/k8s/00-config.yaml new file mode 100644 index 0000000..fb14e4e --- /dev/null +++ b/deploy/k8s/00-config.yaml @@ -0,0 +1,120 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: dns +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fancy-dns-config + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +data: + config.yaml: | + # Rendered by the deployment below; kept in a ConfigMap so a change is a + # `kubectl apply` rather than a rebuild. + server: + max_concurrent: 2048 + timeouts: + upstream: 2s + list_fetch: 60s + + dns: + udp: ["0.0.0.0:53", "[::]:53"] + tcp: ["0.0.0.0:53", "[::]:53"] + max_udp_size: 1232 + dot: + enabled: true + listen: ["0.0.0.0:853"] + cert_file: /etc/fancy-dns/tls/tls.crt + key_file: /etc/fancy-dns/tls/tls.key + doh: + enabled: true + listen: ["0.0.0.0:443"] + cert_file: /etc/fancy-dns/tls/tls.crt + key_file: /etc/fancy-dns/tls/tls.key + base_path: /dns-query + + upstream: + policy: parallel + max_attempts: 3 + healthcheck_interval: 30s + servers: + - addr: 9.9.9.9:53 + proto: udp + - addr: 1.1.1.1:53 + proto: tls + server_name: cloudflare-dns.com + - addr: 9.9.9.9:853 + proto: tls + server_name: dns.quad9.net + + cache: + enabled: true + size: 500000 + max_ttl: 1h + serve_stale: 30m + persist: + enabled: true + path: /var/lib/fancy-dns/cache.db + interval: 30s + + blocklist: + enabled: true + default_action: nxdomain + local_zones: [/etc/fancy-dns/zones/*.zone] + lists: + - name: haas + format: plain + url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts + path: /var/lib/fancy-dns/lists/hosts + enabled: true + refresh: 24h + categories: [ads, tracking] + + acl: + default_policy: allow + groups: [] + + qlog: + enabled: true + path: /var/lib/fancy-dns/queries.db + max_entries: 200000 + sample_rate: 1.0 + log_blocked: true + log_cached: false + + api: + enabled: true + listen: ["0.0.0.0:8080"] + base_path: /api + ui: true + allow_config_write: false + # Read the key from a Secret and mount it; the value here is a placeholder + # that MUST be replaced before this reaches a network. + auth: + mode: apikey + api_keys: ["REPLACE-ME"] + trust_loopback: false + + metrics: + enabled: true + path: /metrics + protect: true + + log: + level: info + format: json +--- +apiVersion: v1 +kind: Secret +metadata: + name: fancy-dns-tls + namespace: dns +type: kubernetes.io/tls +# Replace with a real cert-manager managed secret in production: +# cert-manager.io/v1 -> Certificate -> CertificateRequest -> Secret +stringData: + tls.crt: "" + tls.key: "" diff --git a/deploy/k8s/10-deployment.yaml b/deploy/k8s/10-deployment.yaml new file mode 100644 index 0000000..8cc57dd --- /dev/null +++ b/deploy/k8s/10-deployment.yaml @@ -0,0 +1,245 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: fancy-dns + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +spec: + serviceName: fancy-dns + replicas: 2 + podManagementPolicy: Parallel + selector: + matchLabels: + app.kubernetes.io/name: fancy-dns + template: + metadata: + labels: + app.kubernetes.io/name: fancy-dns + annotations: + # A config change is a SIGHUP, not a restart: the server re-reads the + # file and hot-swaps every component. + reloader.stakater.com/auto: "true" + spec: + # DNS on a non-privileged port needs NET_BIND_SERVICE on Linux, or + # hostNetwork. hostNetwork is used here so the node's port 53 is served + # directly and the load balancer does not add a hop. + hostNetwork: true + dnsPolicy: Default + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: fancy-dns + containers: + - name: fancy-dns + image: fancydns/fancy-dns:latest + imagePullPolicy: IfNotPresent + args: + - -config + - /etc/fancy-dns/config.yaml + ports: + - name: dns-udp + containerPort: 53 + protocol: UDP + - name: dns-tcp + containerPort: 53 + protocol: TCP + - name: dot + containerPort: 853 + protocol: TCP + - name: doh + containerPort: 443 + protocol: TCP + - name: admin + containerPort: 8080 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # Liveness: the process is up. /api/healthz is unauthenticated on + # purpose, because a probe cannot carry an API key. + livenessProbe: + httpGet: + path: /api/healthz + port: admin + initialDelaySeconds: 5 + periodSeconds: 20 + # Readiness: the listener is actually bound, so the Service does not + # send traffic to a pod that is still binding. + readinessProbe: + httpGet: + path: /api/healthz + port: admin + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + # Startup: binding :53 and fetching the first blocklist can take a + # while on a cold node, so give it room before the liveness probe + # starts killing it. + startupProbe: + httpGet: + path: /api/healthz + port: admin + periodSeconds: 3 + failureThreshold: 30 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + # No CPU limit on purpose: a DNS server that gets throttled + # turns into dropped queries, which is worse than a noisy + # neighbour. The memory limit is the one that matters, because + # the cache is sized in entries. + memory: 1Gi + volumeMounts: + - name: config + mountPath: /etc/fancy-dns/config.yaml + subPath: config.yaml + readOnly: true + - name: zones + mountPath: /etc/fancy-dns/zones + readOnly: true + - name: tls + mountPath: /etc/fancy-dns/tls + readOnly: true + - name: data + mountPath: /var/lib/fancy-dns + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: fancy-dns-config + - name: zones + configMap: + name: fancy-dns-zones + optional: true + - name: tls + secret: + secretName: fancy-dns-tls + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: fancy-dns + namespace: dns + labels: + app.kubernetes.io/name: fancy-dns +spec: + clusterIP: None + selector: + app.kubernetes.io/name: fancy-dns + ports: + - name: dns-udp + port: 53 + targetPort: dns-udp + protocol: UDP + - name: dns-tcp + port: 53 + targetPort: dns-tcp + protocol: TCP + - name: dot + port: 853 + targetPort: dot + - name: doh + port: 443 + targetPort: doh + - name: admin + port: 8080 + targetPort: admin +--- +# The admin console and the DoH endpoint share a port only if the ingress +# terminates TLS; when it does, this routes /api and /dns-query to the same +# backend. Kept separate from the Service above because a headless Service +# cannot front an Ingress. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fancy-dns + namespace: dns + annotations: + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" +spec: + rules: + - host: dns.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: admin + - path: /dns-query + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: doh + - path: / + pathType: Prefix + backend: + service: + name: fancy-dns + port: + name: admin + tls: + - hosts: [dns.example.com] + secretName: fancy-dns-tls +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: fancy-dns + namespace: dns +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: fancy-dns +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: fancy-dns + namespace: dns +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: StatefulSet + name: fancy-dns + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleDown: + # A DNS server that flaps is worse than one that is briefly oversized: + # every scale-down drops the local cache and the blocklist lease. + stabilizationWindowSeconds: 600 diff --git a/internal/blocklist/parse_test.go b/internal/blocklist/parse_test.go new file mode 100644 index 0000000..1126780 --- /dev/null +++ b/internal/blocklist/parse_test.go @@ -0,0 +1,579 @@ +package blocklist + +import ( + "strconv" + "strings" + "testing" +) + +// ruleStrings renders parsed rules compactly for table comparison. +func ruleStrings(rules []Rule) []string { + out := make([]string, 0, len(rules)) + for _, r := range rules { + s := r.Name + " " + r.Action.String() + " ttl=" + strconv.Itoa(int(r.TTL)) + if r.Response != "" { + s += " resp=" + r.Response + } + if r.Priority != 0 { + s += " prio=" + strconv.Itoa(r.Priority) + } + out = append(out, s) + } + return out +} + +// sameRules compares parsed rules against the expectation, treating "no rules" +// as equal whether the parser returned a nil or an empty slice. +func sameRules(got, want []string) bool { + return strings.Join(got, "\n") == strings.Join(want, "\n") +} + +func TestParseHosts(t *testing.T) { + tests := []struct { + name string + data string + opt ParseOptions + want []string + wantFormat Format + wantComment int + wantSkip int + wantBad int + wantNotice string + }{ + { + name: "block entry", + data: "0.0.0.0 ads.example.com\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.ads.example.com block ttl=60"}, + }, + { + name: "several names on one line", + data: "0.0.0.0 a.example.com b.example.com\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.a.example.com block ttl=60", "**.b.example.com block ttl=60"}, + }, + { + name: "loopback bootstrap lines are never blocked", + data: "127.0.0.1 localhost\n::1 ip6-localhost\n", + opt: ParseOptions{Subdomains: true}, + wantSkip: 2, + }, + { + name: "comments, blank lines and inline comments", + data: "# a comment\n; another\n\n \n0.0.0.0 ads.example.com # trailing note\n", + opt: ParseOptions{Subdomains: true}, + want: []string{"**.ads.example.com block ttl=60"}, + wantComment: 2, + }, + { + name: "custom TTL and no subdomain expansion", + data: "0.0.0.0 ads.example.com\n", + opt: ParseOptions{Subdomains: false, DefaultTTL: 300}, + want: []string{"ads.example.com block ttl=300"}, + }, + { + name: "a single field is not a hosts line", + data: "ads.example.com\n", + opt: ParseOptions{Subdomains: true}, + wantBad: 1, + }, + { + name: "a non-address first field falls back to a plain domain list", + data: "0.0.0.0 ads.example.com\nfoo bar.example.com\n", + opt: ParseOptions{Subdomains: true}, + // The fallback re-parses the whole file as a plain list, so the + // real hosts lines survive and the odd line is malformed. + want: []string{"**.ads.example.com block ttl=60"}, + wantFormat: FormatPlain, + wantBad: 1, + wantNotice: "no address in first field, falling back to a plain domain list", + }, + { + name: "malformed lines", + data: "0.0.0.0\n0.0.0.0 not_a_domain..x\n0.0.0.0 -bad.example.com\n", + opt: ParseOptions{Subdomains: true}, + wantBad: 3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rules, stats, err := Parse(FormatHosts, []byte(tc.data), tc.opt) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(rules); !sameRules(got, tc.want) { + t.Errorf("rules = %v, want %v", got, tc.want) + } + wantFormat := tc.wantFormat + if wantFormat == "" { + wantFormat = FormatHosts + } + if stats.Format != wantFormat { + t.Errorf("stats.Format = %q, want %q", stats.Format, wantFormat) + } + if stats.Comments != tc.wantComment { + t.Errorf("Comments = %d, want %d", stats.Comments, tc.wantComment) + } + if stats.Skipped != tc.wantSkip { + t.Errorf("Skipped = %d, want %d", stats.Skipped, tc.wantSkip) + } + if stats.Malformed != tc.wantBad { + t.Errorf("Malformed = %d, want %d", stats.Malformed, tc.wantBad) + } + if stats.Rules != len(rules) { + t.Errorf("Rules = %d, want %d", stats.Rules, len(rules)) + } + if stats.Bytes != int64(len(tc.data)) { + t.Errorf("Bytes = %d, want %d", stats.Bytes, len(tc.data)) + } + if tc.wantNotice != "" { + found := false + for _, n := range stats.Notices { + if n == tc.wantNotice { + found = true + } + } + if !found { + t.Errorf("Notices = %v, want %q", stats.Notices, tc.wantNotice) + } + } + }) + } +} + +func TestParseAdblock(t *testing.T) { + data := strings.Join([]string{ + "[Adblock Plus 2.0]", + "! Title: test list", + "! Version: 1", + "||ads.example.com^", + "||track.example.com^$third-party", + "|http://banner.example.com/img.png|", + "|https://user:pw@secure.example.com:8443/x?y#z|", + "@@||ok.example.com^", + "example.com##.ad", + "banner.example.com#@#.sponsor", + "/ad[0-9]+/", + "/banner[0-9]+/", + "||", + "bare.example.com", + }, "\n") + rules, stats, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + "**.ads.example.com block ttl=60", + "**.track.example.com block ttl=60", + "**.banner.example.com block ttl=60", + "**.secure.example.com block ttl=60", + "**.ok.example.com allow ttl=60 prio=10", + "**.bare.example.com block ttl=60", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Rules != len(want) { + t.Errorf("Rules = %d, want %d", stats.Rules, len(want)) + } + if stats.Comments != 3 { // the header plus two "! " comments + t.Errorf("Comments = %d, want 3", stats.Comments) + } + // 2 cosmetic rules, 2 regex rules, 1 empty "||" host. + if stats.Skipped != 5 { + t.Errorf("Skipped = %d, want 5", stats.Skipped) + } + foundNotice := false + for _, n := range stats.Notices { + if n == "skipped regular-expression rules" { + foundNotice = true + } + } + if !foundNotice { + t.Errorf("Notices = %v, want the regex notice", stats.Notices) + } + if stats.Format != FormatAdblock { + t.Errorf("Format = %q", stats.Format) + } +} + +func TestParsePlain(t *testing.T) { + data := strings.Join([]string{ + "# a comment", + "; another comment", + "", + " spaced.example.com ", + "!ok.example.com", + "0.0.0.0 hoststyle.example.com", + "||ads.example.com", + "trailing.example.com.", + "!not a domain line !!", + "-bad.example.com", + }, "\n") + rules, stats, err := Parse(FormatPlain, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + "**.spaced.example.com block ttl=60", + "**.ok.example.com allow ttl=60", + "**.hoststyle.example.com block ttl=60", + "**.ads.example.com block ttl=60", + // A trailing dot survives parsing; Builder.Add strips it later. + "**.trailing.example.com. block ttl=60", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Comments != 2 { + t.Errorf("Comments = %d, want 2", stats.Comments) + } + if stats.Malformed != 2 { + t.Errorf("Malformed = %d, want 2", stats.Malformed) + } + + // Without subdomain expansion the same list yields exact rules. + exact, _, err := Parse(FormatPlain, []byte("spaced.example.com\n!ok.example.com\n"), ParseOptions{Subdomains: false}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + wantExact := []string{"spaced.example.com block ttl=60", "ok.example.com allow ttl=60"} + if got := ruleStrings(exact); !sameRules(got, wantExact) { + t.Errorf("exact rules = %v, want %v", got, wantExact) + } + + // The documented "!allow " spelling is not what the parser + // implements: it only strips the "!" marker, so the word "allow" is left + // in the name and the line is counted as malformed. Real behaviour, + // pinned so a fix shows up as a failure. + allowForm, allowStats, err := Parse(FormatPlain, []byte("!allow ok.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("\"!allow ok.example.com\": rules=%v malformed=%d", ruleStrings(allowForm), allowStats.Malformed) + if allowStats.Rules != 0 || allowStats.Malformed != 1 { + t.Errorf("\"!allow x\": rules=%d malformed=%d, want 0/1 (current behaviour)", + allowStats.Rules, allowStats.Malformed) + } +} + +func TestParseRPZ(t *testing.T) { + data := strings.Join([]string{ + "$TTL 3600", + "; an RPZ policy zone", + "example.com CNAME .", + "*.sub.example.org CNAME rpz-drop.", + "**.both.example.net CNAME rpz-passthru.", + "redirect.example.gov A 127.0.0.2", + "redirect6.example.gov AAAA ::1", + "example.com CNAME .", + `skip.example.com TXT "not-a-known-action"`, + }, "\n") + rules, stats, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := []string{ + // A bare owner name becomes the STRICT-subdomain form "*." (RPZ + // semantics: the policy covers what is under the name), unlike the + // hosts parser which uses "**.". Zone owner names keep their + // trailing dot; Builder.Add strips it at compile time. + "*.example.com. nxdomain ttl=3600", + "*.sub.example.org. drop ttl=3600", + "**.both.example.net. passthrough ttl=3600", + "*.redirect.example.gov. redirect ttl=3600 resp=127.0.0.2", + "*.redirect6.example.gov. redirect ttl=3600 resp=::1", + } + if got := ruleStrings(rules); !sameRules(got, want) { + t.Errorf("rules = %v,\nwant %v", got, want) + } + if stats.Duplicates != 1 { + t.Errorf("Duplicates = %d, want 1 (the repeated example.com record)", stats.Duplicates) + } + if stats.Skipped != 1 { + t.Errorf("Skipped = %d, want 1 (TXT with an unknown action)", stats.Skipped) + } + if stats.Format != FormatRPZ { + t.Errorf("Format = %q, want rpz", stats.Format) + } + + // Without subdomain expansion the wildcard prefix is left off entirely. + plain, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: false}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(plain); !sameRules(got, []string{"example.com. nxdomain ttl=60"}) { + t.Errorf("no-subdomain rules = %v", got) + } + + // A CNAME to a real target is a rewrite rule and $TTL is honoured. + cname, _, err := Parse(FormatRPZ, []byte("$TTL 60\nshop.example.com CNAME parked.example.net.\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := ruleStrings(cname); !sameRules(got, []string{"*.shop.example.com. cname ttl=60 resp=parked.example.net."}) { + t.Errorf("cname rules = %v", got) + } + + // The compiled forms behave as documented: "*." skips the apex while + // "**." covers it. + s := mustBuild(rules...) + if e := s.Match("example.com"); e != nil { + t.Errorf("RPZ \"example.com CNAME .\" matched the apex: %q, want nil (strict \"*.\")", e.Name) + } + if e := s.Match("www.example.com"); e == nil || e.Action != ActionNXDomain { + t.Errorf("www.example.com: %+v, want nxdomain", e) + } + if e := s.Match("both.example.net"); e == nil || e.Action != ActionPassthrough { + t.Errorf("both.example.net: %+v, want passthrough (\"**.\" covers the apex)", e) + } + if e := s.Match("deep.both.example.net"); e == nil || e.Action != ActionPassthrough { + t.Errorf("deep.both.example.net: %+v, want passthrough", e) + } +} + +func TestDetectFormat(t *testing.T) { + tests := []struct { + name string + data string + want Format + }{ + {"hosts", "0.0.0.0 ads.example.com\n127.0.0.1 localhost\n", FormatHosts}, + {"hosts after comments", "# comment\n\n 127.0.0.1 localhost\n", FormatHosts}, + {"hosts with ::1", "::1 ip6-localhost\n", FormatHosts}, + {"adblock double pipe", "||ads.example.com^\n", FormatAdblock}, + {"adblock allow", "@@||ok.example.com^\n", FormatAdblock}, + {"adblock cosmetic", "example.com##.ad\n", FormatAdblock}, + {"adblock after header", "[Adblock Plus 2.0]\n! comment\n|http://a.example.com/x|\n", FormatAdblock}, + {"rpz ttl", "$TTL 3600\nexample.com CNAME .\n", FormatRPZ}, + {"rpz origin", "$ORIGIN example.com.\nexample.com CNAME .\n", FormatRPZ}, + {"rpz record with ttl and class", "example.com 3600 IN CNAME .\n", FormatRPZ}, + {"single plain domain", "ads.example.com\n", FormatPlain}, + {"empty", "", FormatPlain}, + {"only comments", "# nothing here\n; really\n", FormatPlain}, + // A two-line plain list is sniffed as adblock: the second meaningful + // line is taken to be an adblock rule. Both parsers turn a bare + // hostname into a block rule, so the rules still come out right. + {"two plain domains", "ads.example.com\ntrack.example.com\n", FormatAdblock}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := DetectFormat([]byte(tc.data)); got != tc.want { + t.Errorf("DetectFormat(%q) = %q, want %q", tc.data, got, tc.want) + } + }) + } +} + +func TestDetectFormatSuspectedBugs(t *testing.T) { + // Real behaviour today, pinned so a future fix shows up as a failure. + // allDigitsOrName() does not know RR type names, so a policy record + // without an explicit TTL/class column is sniffed as a plain domain + // list. Parsing the same content as RPZ works, which isolates the + // detection step. + if got := DetectFormat([]byte("example.com CNAME .\n")); got != FormatPlain { + t.Errorf("DetectFormat(rpz record) = %q, want plain (current behaviour)", got) + } + rules, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: true}) + if err != nil || len(rules) != 1 { + t.Errorf("explicit RPZ parse of the same record: rules=%v err=%v", ruleStrings(rules), err) + } + + // The "0 address" form that FormatHosts documents as accepted is not an + // IP literal for netip, so the hosts parser counts the line as malformed + // instead of blocking the name. + _, stats, err := Parse(FormatHosts, []byte("0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if stats.Rules != 0 || stats.Malformed != 1 { + t.Errorf("\"0 ads.example.com\": rules=%d malformed=%d, want 0/1 (current behaviour)", + stats.Rules, stats.Malformed) + } +} + +func TestParseAutoPicksFormat(t *testing.T) { + rules, stats, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if stats.Format != FormatHosts { + t.Errorf("Format = %q, want hosts", stats.Format) + } + if len(rules) != 1 || rules[0].Name != "**.ads.example.com" { + t.Errorf("rules = %v", ruleStrings(rules)) + } + // An empty format string means "sniff" too. + if _, stats, _ := Parse("", []byte("||ads.example.com^\n"), ParseOptions{Subdomains: true}); stats.Format != FormatAdblock { + t.Errorf("Format = %q, want adblock", stats.Format) + } + if _, _, err := Parse("bogus", []byte("x"), ParseOptions{}); err == nil { + t.Errorf("Parse with an unknown format returned no error") + } +} + +func TestParseRobustness(t *testing.T) { + long := "long" + strings.Repeat("x", 100000) + ".example.com" + bigDomain := strings.Repeat("d", 250) + ".example.com" // over the 253-char limit + hugeDomain := strings.Repeat("d", 3000) + ".example.com" + + listSamples := map[string]string{ + "crlf": "0.0.0.0 ads.example.com\r\n0.0.0.0 b.example.com\r\n", + "lone cr": "0.0.0.0 ads.example.com\r0.0.0.0 b.example.com\r", + "leading/trailing space": "\t 0.0.0.0 ads.example.com \n", + "blank and space-only": "\n\n \n\t\n", + "very long line": "0.0.0.0 " + long + "\n", + "over-long label": "0.0.0.0 " + bigDomain + "\n", + "comments everywhere": "# c\n; c\n0.0.0.0 ads.example.com # tail\n\n", + } + for name, data := range listSamples { + t.Run("list/"+name, func(t *testing.T) { + for _, format := range []Format{FormatHosts, FormatPlain, FormatAdblock} { + rules, stats, err := Parse(format, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("%s: Parse: %v", format, err) + continue + } + if stats.Rules != len(rules) { + t.Errorf("%s: Rules = %d, want %d", format, stats.Rules, len(rules)) + } + for _, r := range rules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("%s: rule name kept whitespace: %q", format, r.Name) + } + } + } + }) + } + + // The adblock and plain parsers must not be confused by CRLF either. + for _, data := range []string{ + "||ads.example.com^\r\n@@||ok.example.com^\r\n", + "a.example.com\r\nb.example.com\r\n", + } { + for _, format := range []Format{FormatPlain, FormatAdblock} { + rules, _, err := Parse(format, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("%s: Parse(%q): %v", format, data, err) + continue + } + if len(rules) == 0 { + t.Errorf("%s: Parse(%q) produced no rules", format, data) + } + for _, r := range rules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("%s: rule name kept whitespace: %q", format, r.Name) + } + } + } + } + + // Specifically: CRLF does not leak a CR into the rule names. + rules, _, err := Parse(FormatHosts, []byte("0.0.0.0 ads.example.com\r\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(rules) != 1 || rules[0].Name != "**.ads.example.com" { + t.Errorf("CRLF rules = %v", ruleStrings(rules)) + } + s := mustBuild(rules...) + if s.Match("ads.example.com") == nil || s.Match("x.ads.example.com") == nil { + t.Errorf("CRLF-parsed rule does not match: %v", ruleStrings(rules)) + } + + // The RPZ parser goes through the zone-file reader: CRLF, comments, + // directives and blank lines are handled there too. + for _, data := range []string{ + "$TTL 60\r\nexample.com CNAME .\r\n*.sub.example.org CNAME rpz-drop.\r\n", + "; comment\r\n\r\nexample.com 60 IN CNAME .\n", + "$ORIGIN example.com.\n$TTL 60\nwww IN A 127.0.0.1\n", + } { + zrules, _, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true}) + if err != nil { + t.Errorf("Parse(rpz %q): %v", data, err) + continue + } + for _, r := range zrules { + if strings.ContainsAny(r.Name, "\r\n\t ") { + t.Errorf("rule name kept whitespace: %q", r.Name) + } + } + } + + // A line longer than the scanner's 4 MiB cap is reported, not silently + // truncated. + _, _, err = Parse(FormatPlain, []byte("a.example.com\n"+hugeDomain+"\n"), ParseOptions{Subdomains: true}) + if err == nil { + t.Errorf("an over-long line parsed without error") + } else { + t.Logf("over-long line error (expected): %v", err) + } +} + +func TestParseBOMBehaviour(t *testing.T) { + // A UTF-8 BOM on the first line is not stripped by the parsers, so the + // first rule of the list is lost. Real behaviour, pinned. + const bom = "\ufeff" + _, stats, err := Parse(FormatAuto, []byte(bom+"0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("BOM sample: format=%s rules=%d malformed=%d notices=%v", + stats.Format, stats.Rules, stats.Malformed, stats.Notices) + if stats.Rules != 0 || stats.Malformed != 1 { + t.Errorf("BOM sample: rules=%d malformed=%d, want 0/1 (current behaviour)", + stats.Rules, stats.Malformed) + } + + // Without the BOM the same content parses. + _, ok, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if ok.Rules != 1 { + t.Errorf("control: rules = %d, want 1", ok.Rules) + } + + // A BOM on an adblock list behaves the same way: the first rule is lost. + abRules, abStats, err := Parse(FormatAdblock, []byte(bom+"||ads.example.com^\n"), ParseOptions{Subdomains: true}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + t.Logf("BOM adblock: rules=%v skipped=%d", ruleStrings(abRules), abStats.Skipped) + if abStats.Rules != 0 { + t.Errorf("BOM adblock rules = %v, want none (current behaviour)", ruleStrings(abRules)) + } +} + +func TestParsedRulesCompileAndMatch(t *testing.T) { + // End to end: an adblock list with a broad root rule plus a narrow + // allow rule compiles into a set where the allow wins for the name it + // names and the root still covers everything else. + data := "||example.com^\n@@||metrics.example.com^\n" + rules, _, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 300}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + b := NewBuilder() + for _, r := range rules { + b.Add(r, "list", []string{"ads"}) + } + s := b.Build() + if e := s.Match("ads.example.com"); e == nil || e.Action != ActionBlock { + t.Errorf("ads.example.com: %+v, want block", e) + } + if e := s.Match("example.com"); e == nil || e.Action != ActionBlock { + t.Errorf("example.com: %+v, want block (root rule covers the apex)", e) + } + if e := s.Match("metrics.example.com"); e == nil || !e.IsAllow() { + t.Errorf("metrics.example.com: %+v, want allow", e) + } + if e := s.Match("a.b.metrics.example.com"); e == nil || !e.IsAllow() { + t.Errorf("a.b.metrics.example.com: %+v, want allow (the allow rule is a root rule)", e) + } + for _, r := range rules { + if e := s.Lookup(r.Name); e != nil && e.TTL != 300 { + t.Errorf("TTL = %d, want 300", e.TTL) + } + } +} diff --git a/internal/blocklist/set_test.go b/internal/blocklist/set_test.go new file mode 100644 index 0000000..de82df7 --- /dev/null +++ b/internal/blocklist/set_test.go @@ -0,0 +1,525 @@ +package blocklist + +import ( + "fmt" + "sync" + "testing" +) + +// mustBuild compiles rules into an immutable Set. +func mustBuild(rules ...Rule) *Set { + b := NewBuilder() + for _, r := range rules { + b.Add(r, "test", nil) + } + return b.Build() +} + +func block(name string) Rule { return Rule{Name: name, Action: ActionBlock} } + +func TestSetMatch(t *testing.T) { + s := mustBuild( + block("example.com"), // exact: the name and nothing else + block("*.wild.example.com"), // strict subdomains only + block("**.root.example.net"), // apex and subdomains + block("**.sub.example.org"), // whole subtree + block("ads.example.com"), // more specific than the exact "example.com" + Rule{Name: "tracker.example.com", Action: ActionAllow}, // exact allow + ) + + tests := []struct { + name string + query string + want string // stored rule name, "" = no match + kind PatternKind + action Action + explains string + }{ + {name: "exact apex", query: "example.com", want: "example.com", kind: PatternExact}, + {name: "exact apex trailing dot", query: "example.com.", want: "example.com", kind: PatternExact}, + {name: "exact apex uppercase", query: "ExAmPlE.CoM", want: "example.com", kind: PatternExact}, + {name: "exact rule does not cover subdomains", query: "sub.example.com", want: "", kind: PatternExact}, + {name: "sub wildcard matches subdomain", query: "a.wild.example.com", want: "wild.example.com", kind: PatternSub}, + {name: "sub wildcard does not match apex", query: "wild.example.com", want: "", kind: PatternExact}, + {name: "root wildcard matches apex", query: "root.example.net", want: "root.example.net", kind: PatternRoot}, + {name: "root wildcard matches subdomain", query: "deep.a.root.example.net", want: "root.example.net", kind: PatternRoot}, + {name: "subtree", query: "a.b.sub.example.org", want: "sub.example.org", kind: PatternRoot}, + {name: "subtree apex", query: "sub.example.org", want: "sub.example.org", kind: PatternRoot}, + {name: "most specific exact wins over root", query: "ads.example.com", want: "ads.example.com", kind: PatternExact}, + {name: "allow rule reported with its action", query: "tracker.example.com", want: "tracker.example.com", kind: PatternExact, action: ActionAllow}, + {name: "unrelated name", query: "unrelated.test", want: ""}, + {name: "empty name", query: "", want: ""}, + {name: "the root label is normalised away", query: ".", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := s.Match(tc.query) + if tc.want == "" { + if got != nil { + t.Fatalf("Match(%q) = %q, want no match", tc.query, got.Name) + } + return + } + if got == nil { + t.Fatalf("Match(%q) = nil, want %q", tc.query, tc.want) + } + if got.Name != tc.want { + t.Errorf("Match(%q) rule = %q, want %q", tc.query, got.Name, tc.want) + } + wantAction := tc.action + if wantAction == ActionBlock && got.Action != ActionAllow { + wantAction = ActionBlock + } + if got.Action != wantAction { + t.Errorf("Match(%q) action = %v, want %v", tc.query, got.Action, wantAction) + } + if k := s.MatchPattern(tc.query); k != tc.kind { + t.Errorf("MatchPattern(%q) = %v, want %v", tc.query, k, tc.kind) + } + }) + } +} + +func TestSetMatchLeadingDotAndSubdomainToggle(t *testing.T) { + // splitPattern maps a leading dot to the "**." (root) form. + s := mustBuild(block(".dot.example.com")) + if e := s.Match("dot.example.com"); e == nil { + t.Errorf("leading-dot rule did not match its apex") + } + if e := s.Match("x.dot.example.com"); e == nil { + t.Errorf("leading-dot rule did not match a subdomain") + } + + // With Subdomains off the parsers emit exact rules, so nothing below the + // apex is covered. + exact := mustBuild(hostsRule("ads.example.com", ParseOptions{Subdomains: false})) + if e := exact.Match("sub.ads.example.com"); e != nil { + t.Errorf("exact hosts rule matched a subdomain: %q", e.Name) + } + if e := exact.Match("ads.example.com"); e == nil { + t.Errorf("exact hosts rule did not match its own name") + } + if exact.HasWildcards() { + t.Errorf("HasWildcards() = true for an exact-only set") + } +} + +func TestSetDropsUnusablePatterns(t *testing.T) { + // "." is "the whole internet" and is never useful: it is dropped. A bare + // TLD, on the other hand, is kept and matches the TLD itself. + s := mustBuild(block("."), block("*.com"), block("com"), block(""), block(" ")) + if s.Len() != 2 { + t.Fatalf("Len() = %d, want 2 (exact \"com\" and sub \"com\")", s.Len()) + } + if e := s.Match("com"); e == nil || e.Name != "com" { + t.Errorf("Match(\"com\") = %v, want the exact rule \"com\"", e) + } + if e := s.Match("ads.com"); e == nil || e.Name != "com" { + t.Errorf("Match(\"ads.com\") = %v, want sub rule \"com\"", e) + } +} + +func TestSetMatchesWholeSubtree(t *testing.T) { + // A single root rule on a TLD covers its whole subtree. + s := mustBuild(block("**.com")) + for _, q := range []string{"com", "example.com", "a.b.c.example.com", "EXAMPLE.COM."} { + if e := s.Match(q); e == nil || e.Name != "com" { + t.Errorf("Match(%q) = %v, want the \"**.com\" rule", q, e) + } + } + for _, q := range []string{"de", "example.de", "com.evil.test"} { + if e := s.Match(q); e != nil { + t.Errorf("Match(%q) = %q, want no match", q, e.Name) + } + } + // The apex is covered by "**." but not by "*.". + sub := mustBuild(block("*.com")) + if e := sub.Match("com"); e != nil { + t.Errorf("\"*.com\" matched the apex: %q", e.Name) + } + if e := sub.Match("example.com"); e == nil { + t.Errorf("\"*.com\" did not match a subdomain") + } + // A single label or an empty name never reaches any map. + if e := s.Match(""); e != nil { + t.Errorf("Match(\"\") = %q, want nil", e.Name) + } +} + +func TestNormalizeName(t *testing.T) { + tests := []struct{ in, want string }{ + {"example.com", "example.com"}, + {"example.com.", "example.com"}, + {"EXAMPLE.com..", "example.com."}, // one trailing dot, then lowercased + {"", ""}, + {".", ""}, + {"..", "."}, + } + for _, tc := range tests { + if got := NormalizeName(tc.in); got != tc.want { + t.Errorf("NormalizeName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Match must treat "example.com." and "example.com" as the same name, + // and must be case-insensitive. + s := mustBuild(block("example.com")) + if s.Match("example.com.") == nil || s.Match("EXAMPLE.COM") == nil { + t.Errorf("Match is not case/trailing-dot insensitive") + } +} + +func TestAllowOverBlockPriority(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "cdn.example.com", Action: ActionBlock}, "blocklist", nil) + b.Add(Rule{Name: "cdn.example.com", Action: ActionAllow}, "allowlist", []string{"user"}) + s := b.Build() + + e := s.Match("cdn.example.com") + if e == nil { + t.Fatal("no rule matched") + } + if !e.IsAllow() { + t.Errorf("IsAllow() = false for %v", e.Action) + } + if e.Action != ActionAllow { + t.Errorf("action = %v, want allow", e.Action) + } + if e.Source != "allowlist" { + t.Errorf("Source = %q, want %q (the winning rule's list)", e.Source, "allowlist") + } + if e.Priority != 10 { + t.Errorf("Priority = %d, want 10 (allow default)", e.Priority) + } + if got := e.Categories; len(got) != 1 || got[0] != "user" { + t.Errorf("Categories = %v, want [user]", got) + } + c := s.Counts() + if c.Allow != 1 || c.Block != 0 { + t.Errorf("Counts() = %+v, want Allow=1 Block=0 (block rule was replaced)", c) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } + if s.Lookup("cdn.example.com").Action != ActionAllow { + t.Errorf("Lookup() disagrees with Match()") + } +} + +func TestPriorityTieKeepsFirstRule(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "first", nil) + b.Add(Rule{Name: "tie.example.com", Action: ActionBlock, Priority: 5}, "second", nil) + s := b.Build() + if s.Lookup("tie.example.com").Source != "first" { + t.Errorf("on a priority tie the later rule won: source = %q", s.Lookup("tie.example.com").Source) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } +} + +func TestPassthroughCountsAsAllow(t *testing.T) { + b := NewBuilder() + b.Add(Rule{Name: "pt.example.com", Action: ActionPassthrough}, "rpz", nil) + s := b.Build() + e := s.Match("pt.example.com") + if e == nil || !e.IsAllow() { + t.Fatalf("passthrough rule: IsAllow() = false, e = %v", e) + } + if c := s.Counts(); c.Passthru != 1 || c.Allow != 0 { + t.Errorf("Counts() = %+v, want Passthru=1", c) + } +} + +func TestHits(t *testing.T) { + s := mustBuild(block("hit.example.com"), block("*.wild.hit.example.com")) + const n = 3 + for i := 0; i < n; i++ { + if s.Match("hit.example.com") == nil { + t.Fatal("setup: no match") + } + } + e := s.Match("hit.example.com") + if got := e.Hits(); got != n+1 { + t.Errorf("Hits() = %d, want %d (every Match counts, including this one)", got, n+1) + } + // Non-matching queries must not bump any counter. + s.Match("miss.example.com") + if got := s.Match("hit.example.com").Hits(); got != n+2 { + t.Errorf("Hits() = %d after a miss, want %d", got, n+2) + } + // A wildcard rule has its own counter, and the shared *Entry is visible + // through every pointer to it. + if got := s.Match("a.wild.hit.example.com").Hits(); got != 1 { + t.Errorf("wildcard Hits() = %d, want 1", got) + } + if got := s.Match("hit.example.com").Hits(); got != n+3 { + t.Errorf("exact Hits() = %d, want %d", got, n+3) + } + // A zero-valued Entry is safe. + var zero Entry + if zero.Hits() != 0 { + t.Errorf("zero Entry Hits() = %d, want 0", zero.Hits()) + } + if got := (*Entry)(nil).Hits(); got != 0 { + t.Errorf("nil Entry Hits() = %d, want 0", got) + } +} + +func TestDuplicateDomainsAcrossLists(t *testing.T) { + // The same domain from two lists compiles to ONE rule: the dedup happens + // in Builder.Add, keyed on the normalised pattern, and the losing rule is + // dropped (Duplicates counts it) rather than kept as a second entry. + b := NewBuilder() + b.Add(Rule{Name: "Dup.Example.com.", Action: ActionBlock}, "list-a", nil) + b.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-b", nil) + s := b.Build() + + if s.Len() != 1 { + t.Fatalf("Len() = %d, want 1 (duplicates are deduped)", s.Len()) + } + if s.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s.Duplicates()) + } + e := s.Match("dup.example.com") + if e == nil { + t.Fatal("no match") + } + if e.Source != "list-a" { + t.Errorf("Source = %q, want list-a (first list wins on a tie)", e.Source) + } + // One Entry => one hit counter, regardless of how many lists listed it. + if got := e.Hits(); got != 1 { + t.Errorf("Hits() = %d, want 1 (deduped rules share one counter)", got) + } + // A higher-priority rule from a later list replaces the earlier one. + b2 := NewBuilder() + b2.Add(Rule{Name: "dup.example.com", Action: ActionBlock}, "list-a", nil) + b2.Add(Rule{Name: "dup.example.com", Action: ActionAllow}, "list-b", nil) + s2 := b2.Build() + if a := s2.Match("dup.example.com"); a == nil || a.Source != "list-b" { + t.Errorf("higher-priority later rule did not win: %+v", a) + } + if s2.Duplicates() != 1 { + t.Errorf("Duplicates() = %d, want 1", s2.Duplicates()) + } +} + +func TestSetIntrospection(t *testing.T) { + b := NewBuilder() + b.Add(block("exact.example.com"), "a", nil) + b.Add(block("*.sub.example.com"), "b", nil) + b.Add(block("**.root.example.com"), "c", nil) + b.NoteSource(SourceMeta{Name: "a", Format: "hosts", Rules: 1, Bytes: 42, Enabled: true}) + s := b.Build() + + if got := s.Sources(); len(got) != 3 || got[0] != "a" || got[2] != "c" { + t.Errorf("Sources() = %v, want insertion order [a b c]", got) + } + m, ok := s.SourceMeta("b") + if !ok { + t.Errorf("SourceMeta(\"b\") missing") + } + _ = m + if a, ok := s.SourceMeta("a"); !ok || a.Rules != 1 || a.Bytes != 42 || a.Format != "hosts" { + t.Errorf("SourceMeta(\"a\") = %+v, ok=%v", a, ok) + } + if _, ok := s.SourceMeta("nope"); ok { + t.Errorf("SourceMeta(\"nope\") reported ok") + } + if c := s.Counts(); c.Exact != 1 || c.Sub != 1 || c.Root != 1 || c.Block != 3 { + t.Errorf("Counts() = %+v, want 1/1/1 exact/sub/root and 3 block", c) + } + st := s.Stats() + if st.Total != 3 || st.Exact != 1 || st.Subdomains != 1 || st.RootRules != 1 { + t.Errorf("Stats() = %+v", st) + } + if st.BuiltAt.IsZero() { + t.Errorf("Stats().BuiltAt is zero") + } + + // Each renders patterns back with their wildcard prefix. + seen := map[string]PatternKind{} + s.Each(func(pattern string, e *Entry, kind PatternKind) bool { + seen[pattern] = kind + if got := kind.Pattern(e.Name); got != pattern { + t.Errorf("Pattern(%q) = %q, want %q", e.Name, got, pattern) + } + return true + }) + if seen["exact.example.com"] != PatternExact || seen["sub.example.com"] != PatternSub || + seen["root.example.com"] != PatternRoot { + t.Errorf("Each() visited %v", seen) + } + if PatternSub.String() != "subdomain" || PatternRoot.String() != "domain" || PatternExact.String() != "exact" { + t.Errorf("PatternKind.String() values changed") + } + + // Lookup is exact-only: it does not walk wildcards. + if s.Lookup("deep.sub.example.com") != nil { + t.Errorf("Lookup() walked wildcards") + } + if s.Lookup("EXACT.example.com.") == nil { + t.Errorf("Lookup() is not normalised") + } +} + +func TestDigestStableAndContentSensitive(t *testing.T) { + a := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.z.example.com")) + b := mustBuild(block("**.z.example.com"), block("x.example.com"), block("*.y.example.com")) + if a.Digest() != b.Digest() { + t.Errorf("Digest() depends on insertion order: %q vs %q", a.Digest(), b.Digest()) + } + c := mustBuild(block("x.example.com"), block("*.y.example.com"), block("**.different.example.com")) + if a.Digest() == c.Digest() { + t.Errorf("Digest() ignored a pattern change") + } + // The action is part of the digest. + d := mustBuild(Rule{Name: "x.example.com", Action: ActionAllow}) + if a.Digest() == d.Digest() { + t.Errorf("Digest() ignored the action change") + } +} + +func TestNilSetIsSafe(t *testing.T) { + var s *Set + if s.Match("example.com") != nil || s.Len() != 0 || s.HasWildcards() || + s.DuplicatesN() != 0 || s.Sources() != nil || s.AllSourceMeta() != nil || + s.Digest() != "" || !s.BuiltAt().IsZero() { + t.Errorf("nil *Set accessors are not nil-safe") + } + if _, ok := s.SourceMeta("x"); ok { + t.Errorf("nil Set SourceMeta reported ok") + } + if s.Lookup("x") != nil || s.MatchPattern("x") != PatternExact { + t.Errorf("nil Set lookup misbehaved") + } + if st := s.Stats(); st.Total != 0 || st.PerSource == nil { + t.Errorf("nil Stats() = %+v", st) + } + s.Each(func(string, *Entry, PatternKind) bool { t.Errorf("nil Each called fn"); return true }) +} + +func TestActionStringAndParseAction(t *testing.T) { + all := []struct { + a Action + str string + }{ + {ActionBlock, "block"}, + {ActionAllow, "allow"}, + {ActionPassthrough, "passthrough"}, + {ActionDrop, "drop"}, + {ActionNXDomain, "nxdomain"}, + {ActionZero, "zero"}, + {ActionCNAME, "cname"}, + {ActionRedirect, "redirect"}, + {Action(99), "unknown"}, + } + for _, tc := range all { + if got := tc.a.String(); got != tc.str { + t.Errorf("Action(%d).String() = %q, want %q", tc.a, got, tc.str) + } + } + roundtrip := map[string]Action{ + "": ActionBlock, + "block": ActionBlock, + "ALLOW": ActionAllow, + " whitelist ": ActionAllow, + "pass": ActionPassthrough, + "drop": ActionDrop, + "nxdomain": ActionNXDomain, + "null": ActionZero, + "cname": ActionCNAME, + "redirect": ActionRedirect, + } + for in, want := range roundtrip { + got, ok := ParseAction(in) + if !ok || got != want { + t.Errorf("ParseAction(%q) = %v,%v; want %v,true", in, got, ok, want) + } + } + if got, ok := ParseAction("nonsense"); ok || got != ActionBlock { + t.Errorf("ParseAction(\"nonsense\") = %v,%v; want block,false", got, ok) + } +} + +func TestHolderSwapIsAtomic(t *testing.T) { + old := mustBuild(block("a.example.com")) + newer := mustBuild(block("b.example.com")) + h := NewHolder(old) + if h.Get() != old { + t.Fatalf("NewHolder did not install the set") + } + var empty *Holder + if empty.Get() != nil { + t.Errorf("zero Holder is not empty") + } + // A reader either sees the whole old set or the whole new one. + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + s := h.Get() + if s == nil { + t.Error("holder returned nil") + return + } + if e := s.Match("a.example.com"); e == nil { + t.Error("observed a half-applied set") + return + } + } + }() + } + for i := 0; i < 50; i++ { + h.Set(newer) + h.Set(old) + } + close(stop) + wg.Wait() + if h.Get() != old { + t.Errorf("holder lost the last swap") + } +} + +func BenchmarkSetMatch(b *testing.B) { + // A mixed set, like a compiled blocklist: mostly exact rules plus some + // subdomain and root rules so the suffix walk is exercised. + bld := NewBuilder() + for i := 0; i < 2000; i++ { + bld.Add(block(fmt.Sprintf("host%d.ads-%d.example.com", i, i%7)), "bench", nil) + } + for i := 0; i < 200; i++ { + bld.Add(block(fmt.Sprintf("*.wild%d.example.net", i)), "bench", nil) + bld.Add(block(fmt.Sprintf("**.root%d.example.org", i)), "bench", nil) + } + s := bld.Build() + + queries := []string{ + "host0.ads-0.example.com", // exact hit + "host1999.ads-5.example.com", // exact hit, different list + "a.b.wild199.example.net", // wildcard suffix hit + "x.root42.example.org", // root rule + "nothing.in.here.test", // pure miss, walks every suffix + } + b.ReportAllocs() + b.ResetTimer() + var sink int + for i := 0; i < b.N; i++ { + if e := s.Match(queries[i%len(queries)]); e != nil { + sink++ + } + } + if sink == 0 { + b.Fatal("no matches at all") + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 25372b5..530f2d5 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -18,6 +18,7 @@ package cache import ( "encoding/binary" "strings" + "sync/atomic" "time" "github.com/miekg/dns" @@ -160,8 +161,11 @@ type Entry struct { StaleUntil time.Time // Bytes is the accounted size, used by the LRU and the size gauges. Bytes int - // Hits counts serves since it was stored. - Hits uint32 + // Hits counts serves since it was stored. It is atomic because an Entry is + // shared by every goroutine that hits the key (the LRU hands out the same + // pointer on purpose, so a hit never copies the message), and the + // counter is bumped outside the shard lock on the hot path. + Hits atomic.Uint32 // Neg marks a cached NXDOMAIN or NODATA answer. Neg bool // Truncated records that the upstream answer did not fit in 512 bytes, diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..42c34e4 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,868 @@ +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 != 2 { + t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=2", 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(100, 4) + 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 }) + } + } + }() + for g := 1; g < 8; g++ { + _ = g + } + wg.Wait() + close(stop) + 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 _, tc := range cases { + if got := tc.e.AgeTTL(tc.now); 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) + now := clk.Now() + 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) + } + } + _ = now +} + +func TestManagerTopEntriesCountsAndFlush(t *testing.T) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + for range 5 { + m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up") + } + for range 2 { + m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up") + } + top := m.TopEntries(10) + if len(top) < 2 { + t.Fatalf("TopEntries returned %d rows, want at least 2", len(top)) + } + // Each Put counts too, so a name queried once still shows up. + var hot, warm uint64 + for _, e := range top { + switch e.Name { + case "hot.example.com": + hot = e.Hits + case "warm.example.com": + warm = e.Hits + } + } + if hot < 5 || warm < 2 { + t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm) + } + if top[0].Name != "hot.example.com" { + t.Errorf("first row = %s, want the hottest name first", top[0].Name) + } + m.Flush() + if got := len(m.TopEntries(10)); got != 0 { + t.Errorf("TopEntries after Flush = %d rows, want 0", got) + } +} + +func TestManagerStatsShape(t *testing.T) { + clk := newClock() + m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil) + m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up") + m.Get(key("a.example.com")) + m.Get(key("miss.example.com")) + s := m.Stats() + if !s.Enabled { + t.Error("Enabled = false on a live manager") + } + if s.Entries != 1 { + t.Errorf("Entries = %d, want 1", s.Entries) + } + if s.Hits != 1 || s.Misses != 1 { + t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses) + } + if s.Size != 100 || s.Shards != 4 { + t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards) + } + if s.MemoryHuman == "" || s.Bytes <= 0 { + t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman) + } + if s.Persist.Enabled { + t.Error("Persist.Enabled = true without a store") + } +} + +func TestHumanBytes(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KiB"}, + {1536, "1.5 KiB"}, + {1024 * 1024, "1.0 MiB"}, + {3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"}, + {-1024, "-1.0 KiB"}, + } + for _, tc := range cases { + if got := HumanBytes(tc.in); got != tc.want { + t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestStoreSnapshotAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + + clk := newClock() + now := clk.Now() + l := NewLRU(100, 2) + for i := range 25 { + n := "n" + strconv.Itoa(i) + ".example.com" + wire, err := answer(t, n, 600, "10.0.0.8").Pack() + if err != nil { + t.Fatalf("pack: %v", err) + } + l.Put(key(n), &Entry{ + Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53", + CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire), + }) + } + written, err := st.Snapshot(l, now) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if written != 25 { + t.Errorf("Snapshot wrote %d entries, want 25", written) + } + if fi, err := os.Stat(path); err != nil || fi.Size() == 0 { + t.Fatalf("snapshot file missing or empty: %v", err) + } + + // A fresh LRU restored from the file keeps the answers and their metadata. + restored := NewLRU(100, 2) + loaded, skipped, err := st.Load(restored, now) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 25 || skipped != 0 { + t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped) + } + e, kind := restored.Get(key("n7.example.com"), now) + if e == nil || kind != Fresh { + t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind) + } + if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" { + t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream) + } + // The wire is intact. + msg := new(dns.Msg) + if err := msg.Unpack(e.Wire); err != nil { + t.Fatalf("unpack restored wire: %v", err) + } + if len(msg.Answer) != 1 { + t.Errorf("restored answer has %d records, want 1", len(msg.Answer)) + } +} + +func TestStoreLoadSkipsExpiredEntries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + + clk := newClock() + now := clk.Now() + l := NewLRU(100, 1) + for i, ttl := range []uint32{600, 600, 5} { + n := "n" + strconv.Itoa(i) + ".example.com" + wire, _ := answer(t, n, ttl, "10.0.0.9").Pack() + l.Put(key(n), &Entry{ + Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second), + StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire), + }) + } + if _, err := st.Snapshot(l, now); err != nil { + t.Fatalf("Snapshot: %v", err) + } + // Load 10s later: the 5s entry is dead, the two 600s entries are fresh. + restored := NewLRU(100, 1) + loaded, skipped, err := st.Load(restored, now.Add(10*time.Second)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 2 { + t.Errorf("loaded = %d, want 2", loaded) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1", skipped) + } + if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss { + t.Errorf("expired entry came back after a reload (kind = %v)", kind) + } +} + +func TestStoreTruncateAndStats(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.db") + st, err := OpenStore(StoreOptions{Path: path}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + clk := newClock() + now := clk.Now() + l := NewLRU(100, 1) + wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack() + l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)}) + if _, err := st.Snapshot(l, now); err != nil { + t.Fatalf("Snapshot: %v", err) + } + s := st.Stats() + if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 { + t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s) + } + if !s.LastSnapshot.IsZero() { + t.Logf("LastSnapshot = %v", s.LastSnapshot) + } + if err := st.Truncate(); err != nil { + t.Fatalf("Truncate: %v", err) + } + if s := st.Stats(); s.Entries != 0 { + t.Errorf("entries after Truncate = %d, want 0", s.Entries) + } + restored := NewLRU(100, 1) + loaded, _, err := st.Load(restored, now) + if err != nil { + t.Fatalf("Load after Truncate: %v", err) + } + if loaded != 0 { + t.Errorf("loaded = %d after Truncate, want 0", loaded) + } +} + +// TestManagerFlushDropsSnapshot checks the interaction the blocklist API +// depends on: flushing the cache must also clear the persisted snapshot, or a +// restart would resurrect answers an operator just invalidated. +func TestManagerFlushDropsSnapshot(t *testing.T) { + dir := t.TempDir() + st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")}) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer st.Close() + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st) + k := key("flushed.example.com") + m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up") + if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil { + t.Fatalf("Snapshot: %v", err) + } + m.Flush() + restored := NewLRU(10, 1) + loaded, _, err := st.Load(restored, clk.Now()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded != 0 { + t.Errorf("loaded = %d entries after Flush, want 0", loaded) + } + if res := m.Get(k); res.Kind != Miss { + t.Errorf("kind = %v after Flush, want Miss", res.Kind) + } +} + +func TestLRUStatsHitRate(t *testing.T) { + l := NewLRU(100, 1) + now := newClock().Now() + l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4}) + for range 3 { + l.Get(key("a.example.com"), now) + } + for range 1 { + l.Get(key("nothere.example.com"), now) + } + s := l.Stats() + if s.Hits != 3 || s.Misses != 1 { + t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses) + } + if s.HitRate < 0.74 || s.HitRate > 0.76 { + t.Errorf("HitRate = %v, want ~0.75", s.HitRate) + } +} + +func BenchmarkLRUGet(b *testing.B) { + l := NewLRU(1<<16, 16) + now := time.Now() + names := make([]string, 4096) + for i := range names { + names[i] = "n" + strconv.Itoa(i) + ".example.com" + wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack() + l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)}) + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + l.Get(key(names[i%len(names)]), now) + i += 7 + } + }) +} + +func BenchmarkLRUPut(b *testing.B) { + l := NewLRU(1<<16, 16) + now := time.Now() + b.ResetTimer() + for i := 0; b.Loop(); i++ { + l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64}) + } +} + +func BenchmarkManagerGetUnpack(b *testing.B) { + clk := newClock() + m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil) + names := make([]string, 4096) + for i := range names { + names[i] = "n" + strconv.Itoa(i) + ".example.com" + m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up") + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + m.Get(key(names[i%len(names)])) + i += 7 + } + }) +} diff --git a/internal/cache/lru.go b/internal/cache/lru.go index bf3b5e3..656e3f1 100644 --- a/internal/cache/lru.go +++ b/internal/cache/lru.go @@ -22,8 +22,10 @@ type LRU struct { shards []*shard mask uint64 - // perShard is the per-shard entry budget. - perShard int + // perShard is the per-shard entry budget. It is atomic because a config + // reload resizes the cache from the API goroutine while queries are + // inserting in every shard. + perShard atomic.Int64 // counters are the hot-path stats, kept as atomics so Get does not need // the lock just to record a hit. @@ -69,13 +71,10 @@ func NewLRU(size, shards int) *LRU { l := &LRU{ shards: make([]*shard, shards), mask: uint64(shards - 1), - perShard: size / shards, seed: maphash.MakeSeed(), prefetchGate: make(chan struct{}, 64), } - if l.perShard < 1 { - l.perShard = 1 - } + l.perShard.Store(int64(max(1, size/shards))) for i := range l.shards { l.shards[i] = &shard{ items: make(map[Key]*list.Element, 1024), @@ -140,7 +139,7 @@ func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) { } s.mu.Unlock() - e.Hits++ + e.Hits.Add(1) if kind == Fresh { l.hits.Add(1) } else { @@ -171,7 +170,8 @@ func (l *LRU) Put(k Key, e *Entry) { s.bytes += e.Bytes l.bytes.Add(int64(e.Bytes)) var dropped int - for s.ll.Len() > l.perShard { + budget := int(l.perShard.Load()) + for s.ll.Len() > budget { oldest := s.ll.Back() if oldest == nil { break @@ -347,7 +347,7 @@ func (l *LRU) Stats() LRUStats { Expired: l.expired.Load(), HitRate: rate, Shards: len(l.shards), - Capacity: l.perShard * len(l.shards), + Capacity: int(l.perShard.Load()) * len(l.shards), } } @@ -358,5 +358,5 @@ func (l *LRU) SetCapacity(size, shards int) { shards = len(l.shards) } shards = roundPow2(shards) - l.perShard = max(1, size/shards) + l.perShard.Store(int64(max(1, size/shards))) } diff --git a/internal/qlog/qlog.go b/internal/qlog/qlog.go index 9db6b73..4f14b8d 100644 --- a/internal/qlog/qlog.go +++ b/internal/qlog/qlog.go @@ -90,8 +90,11 @@ type Logger struct { // seq assigns monotonic ids so the UI can detect drops. seq atomic.Uint64 - // live is a fixed-size ring of the most recent entries. - live []liveEntry + // live is a fixed-size ring of the most recent entries. Each slot is a + // pointer, so a writer on the query path never races a reader walking the + // ring: publishing is one atomic word, and a reader sees either the whole + // entry or the one it replaced. + live []atomic.Pointer[liveEntry] livePos atomic.Uint32 liveMu sync.RWMutex liveCond *sync.Cond @@ -184,7 +187,7 @@ func New(opt Options) (*Logger, error) { } l := &Logger{ opts: opt, - live: make([]liveEntry, opt.LiveBuffer), + live: make([]atomic.Pointer[liveEntry], opt.LiveBuffer), counts: newDomainCounts(opt.TopDomains, opt.TopTTL), subs: make(map[int]chan Entry), stop: make(chan struct{}), @@ -258,9 +261,11 @@ func (l *Logger) Log(e Entry) { e.ID = l.seq.Add(1) l.counts.observe(e) - // Live ring. + // Live ring. The slot is published with a single atomic store; a reader + // walking the ring concurrently gets either the old or the new entry, + // never a half-written one. pos := l.livePos.Add(1) - 1 - l.live[pos%uint32(len(l.live))] = liveEntry{entry: e} + l.live[pos%uint32(len(l.live))].Store(&liveEntry{entry: e}) // Fan out to SSE subscribers (non-blocking: a slow consumer drops entries // rather than stalling the query path). @@ -435,7 +440,12 @@ func (l *Logger) Recent(limit int) []Entry { } for i := uint32(0); i < total; i++ { idx := (pos - 1 - i) % uint32(len(l.live)) - out = append(out, l.live[idx].entry) + le := l.live[idx].Load() + if le == nil { + // Never written (or already aged out of the window). + continue + } + out = append(out, le.entry) if len(out) >= limit { break } diff --git a/internal/resolver/pool.go b/internal/resolver/pool.go index f458a34..b1b14a7 100644 --- a/internal/resolver/pool.go +++ b/internal/resolver/pool.go @@ -26,6 +26,10 @@ var ErrNoUpstream = errors.New("resolver: no healthy upstream available") // ErrCircuitOpen is returned by a single upstream whose breaker is open. var ErrCircuitOpen = errors.New("resolver: upstream circuit breaker open") +// lookupTimeout bounds a name resolution when building the pool, so a broken +// system resolver cannot stall a config reload. +const lookupTimeout = 2 * time.Second + // Health is an upstream's current state. type Health string @@ -40,28 +44,46 @@ const ( HealthUnknown Health = "unknown" ) -// Upstream is one configured server plus its live health. +// upstreamStats is everything mutable about an upstream. It lives behind a +// pointer shared by successive Upstream values for the same server, so a +// reload keeps the live health state while the immutable Upstream itself is +// replaced under the readers' feet. +type upstreamStats struct { + queries atomic.Uint64 + failures atomic.Uint64 + consecutive atomic.Uint32 + latencySum atomic.Uint64 + latencyN atomic.Uint64 + + // ring keeps the last 512 latencies in ms for the UI's p95. + mu sync.Mutex + ring []float64 + + breakerOpenUntil atomic.Int64 + health atomic.Pointer[Health] + lastErr atomic.Pointer[string] +} + +func newUpstreamStats() *upstreamStats { + s := &upstreamStats{} + s.health.Store(ptr(HealthUnknown)) + return s +} + +// Upstream is one configured server: its address, its dial-time +// configuration and a pointer to the live stats it shares across reloads. +// +// An Upstream is immutable after construction. Rebind builds a fresh Upstream +// for every server and swaps the whole pool state with one atomic store, so a +// query in flight keeps using a consistent, unchanging view. type Upstream struct { cfg config.UpstreamServer // addr is the address actually dialled: a bootstrap IP for TLS (SNI keeps // the hostname) and the literal address otherwise. addr string - // resolved holds the current bootstrap addresses when the configured - // address is a name. + // resolved holds the bootstrap addresses behind a named upstream. resolved []string - - queries atomic.Uint64 - failures atomic.Uint64 - consecutive atomic.Uint32 - latencySum atomic.Uint64 // microseconds - latencyN atomic.Uint64 - p95 []float64 // recent latencies in ms, for the UI - p95mu sync.Mutex - p95ring []float64 - - breakerOpenUntil atomic.Int64 - health atomic.Pointer[Health] - lastErr atomic.Pointer[string] + stats *upstreamStats } // Addr is the address the resolver dials. @@ -70,29 +92,35 @@ func (u *Upstream) Addr() string { return u.addr } // Config returns the upstream's configuration entry. func (u *Upstream) Config() config.UpstreamServer { return u.cfg } +// Resolved returns the addresses a named upstream resolved to, if any. +func (u *Upstream) Resolved() []string { return u.resolved } + // Health returns the current health state. func (u *Upstream) Health() Health { - if h := u.health.Load(); h != nil { + if u == nil || u.stats == nil { + return HealthUnknown + } + if h := u.stats.health.Load(); h != nil { return *h } return HealthUnknown } -func (u *Upstream) setHealth(h Health) { u.health.Store(&h) } +func (u *Upstream) setHealth(h Health) { u.stats.health.Store(&h) } // SetError records the last error message for the UI. func (u *Upstream) SetError(err error) { if err == nil { - u.lastErr.Store(nil) + u.stats.lastErr.Store(nil) return } s := err.Error() - u.lastErr.Store(&s) + u.stats.lastErr.Store(&s) } // LastError returns the last error message, or "". func (u *Upstream) LastError() string { - if p := u.lastErr.Load(); p != nil { + if p := u.stats.lastErr.Load(); p != nil { return *p } return "" @@ -100,10 +128,11 @@ func (u *Upstream) LastError() string { // Success records a successful query. func (u *Upstream) Success(d time.Duration) { - u.queries.Add(1) - u.consecutive.Store(0) - u.latencySum.Add(uint64(d.Microseconds())) - u.latencyN.Add(1) + s := u.stats + s.queries.Add(1) + s.consecutive.Store(0) + s.latencySum.Add(uint64(d.Microseconds())) + s.latencyN.Add(1) u.recordLatency(d) u.SetError(nil) if u.Health() != HealthUp { @@ -114,12 +143,13 @@ func (u *Upstream) Success(d time.Duration) { // Failure records a failed query and trips the breaker once the threshold is // crossed. func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) { - u.queries.Add(1) - u.failures.Add(1) - n := u.consecutive.Add(1) + s := u.stats + s.queries.Add(1) + s.failures.Add(1) + n := s.consecutive.Add(1) u.SetError(err) if threshold > 0 && int(n) >= threshold { - u.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano()) + s.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano()) u.setHealth(HealthDown) return } @@ -130,32 +160,33 @@ func (u *Upstream) Failure(err error, threshold int, cooldown time.Duration) { // BreakerOpen reports whether the upstream's breaker is open. func (u *Upstream) BreakerOpen() bool { - until := u.breakerOpenUntil.Load() - return until > time.Now().UnixNano() + return u.stats.breakerOpenUntil.Load() > time.Now().UnixNano() } func (u *Upstream) recordLatency(d time.Duration) { ms := float64(d.Microseconds()) / 1000 - u.p95mu.Lock() - if len(u.p95ring) < 512 { - u.p95ring = append(u.p95ring, ms) + s := u.stats + s.mu.Lock() + if len(s.ring) < 512 { + s.ring = append(s.ring, ms) } else { - copy(u.p95ring, u.p95ring[1:]) - u.p95ring[511] = ms + copy(s.ring, s.ring[1:]) + s.ring[511] = ms } - u.p95mu.Unlock() + s.mu.Unlock() } // LatencyStats returns average and p95 latency in milliseconds. func (u *Upstream) LatencyStats() (avg, p95 float64) { - n := u.latencyN.Load() + s := u.stats + n := s.latencyN.Load() if n == 0 { return 0, 0 } - avg = float64(u.latencySum.Load()) / float64(n) / 1000 - u.p95mu.Lock() - samples := append([]float64(nil), u.p95ring...) - u.p95mu.Unlock() + avg = float64(s.latencySum.Load()) / float64(n) / 1000 + s.mu.Lock() + samples := append([]float64(nil), s.ring...) + s.mu.Unlock() if len(samples) == 0 { return avg, 0 } @@ -187,8 +218,11 @@ type Stats struct { // Stats snapshots one upstream. func (u *Upstream) Stats() Stats { - q := u.queries.Load() - f := u.failures.Load() + if u == nil { + return Stats{} + } + q := u.stats.queries.Load() + f := u.stats.failures.Load() var rate float64 if q > 0 { rate = float64(f) / float64(q) @@ -212,83 +246,127 @@ func (u *Upstream) Stats() Stats { } } -// Pool is the set of upstreams plus the policy that selects among them. -type Pool struct { - cfg config.Upstream - mu sync.RWMutex - ups []*Upstream - byTag map[string][]*Upstream - // clients are reused per upstream: one UDP socket, one TCP dialer, one TLS - // session pool. Reusing them is what keeps the per-query cost low. - clients map[*Upstream]*dns.Client - // bootstrap resolves names to addresses. - bootstrap map[string][]string - - rng *rand.Rand - mu2 sync.Mutex +// poolState is an immutable snapshot of the pool: the config it was built +// from, the upstreams, and the address index used for per-client upstream +// overrides. Readers take the pointer once and are then immune to reloads. +type poolState struct { + cfg config.Upstream + ups []*Upstream + // byAddr indexes the upstreams by every address they answer to, so an ACL + // group naming either the configured Addr or the resolved one works. + byAddr map[string][]*Upstream } -// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream -// hostnames must be literal IPs (or resolved by the OS). +func (st *poolState) all() []*Upstream { + if st == nil { + return nil + } + return st.ups +} + +// Pool is the set of upstreams plus the policy that selects among them. +type Pool struct { + state atomic.Pointer[poolState] + // clients are memoised per upstream: one UDP socket, one TCP dialer, one + // TLS session pool. Reusing them is what keeps the per-query cost low; a + // dns.Client is safe for concurrent use. + clients sync.Map // *Upstream -> *dns.Client + + // mu serialises Rebind so two overlapping reloads cannot interleave their + // health hand-over. It is never taken on the query path. + mu sync.Mutex + // rng backs the "random" policy. + rngMu sync.Mutex + rng *rand.Rand +} + +// NewPool builds a pool from config. Bootstraps are used for named upstreams; +// anything unresolved falls back to the system resolver. func NewPool(cfg config.Upstream) *Pool { p := &Pool{ - cfg: cfg, - byTag: make(map[string][]*Upstream), - clients: make(map[*Upstream]*dns.Client), - bootstrap: make(map[string][]string), - rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)), + rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0x9e3779b9)), } - for _, b := range cfg.Bootstraps { - p.bootstrap[b.Name] = append([]string(nil), b.Addresses...) - } - for _, s := range cfg.Servers { - up := &Upstream{cfg: s} - up.setHealth(HealthUnknown) - p.ups = append(p.ups, up) - } - p.resolveAll() + p.state.Store(buildState(cfg, nil, false)) return p } -func (p *Pool) resolveAll() { - for _, up := range p.ups { - host, port, err := net.SplitHostPort(up.cfg.Addr) - if err != nil { - continue - } - if net.ParseIP(host) != nil { - up.addr = up.cfg.Addr - continue - } - if addrs := p.bootstrap[host]; len(addrs) > 0 { - up.resolved = addrs - up.addr = net.JoinHostPort(addrs[0], port) - continue - } - // Fall back to the system resolver. - ips, err := net.LookupIP(host) - if err != nil || len(ips) == 0 { - up.addr = up.cfg.Addr - continue - } - strs := make([]string, 0, len(ips)) - for _, ip := range ips { - strs = append(strs, ip.String()) - } - up.resolved = strs - up.addr = net.JoinHostPort(strs[0], port) +// buildState turns an upstream config into an immutable pool snapshot. When +// carry is non-nil, servers that keep the same address and protocol also keep +// their live health, latency and breaker state. +func buildState(cfg config.Upstream, carry *poolState, lookup bool) *poolState { + bootstraps := make(map[string][]string, len(cfg.Bootstraps)) + for _, b := range cfg.Bootstraps { + bootstraps[b.Name] = append([]string(nil), b.Addresses...) } + prev := make(map[string]*Upstream) + if carry != nil { + for _, u := range carry.ups { + prev[u.cfg.Addr+"/"+u.cfg.Proto] = u + } + } + + st := &poolState{cfg: cfg, ups: make([]*Upstream, 0, len(cfg.Servers))} + for _, s := range cfg.Servers { + addr, resolved := resolveAddr(s, bootstraps, lookup) + up := &Upstream{cfg: s, addr: addr, resolved: resolved} + if old, ok := prev[s.Addr+"/"+s.Proto]; ok { + up.stats = old.stats + } else { + up.stats = newUpstreamStats() + } + st.ups = append(st.ups, up) + } + st.byAddr = make(map[string][]*Upstream, len(st.ups)*2) + for _, up := range st.ups { + for _, a := range append([]string{up.cfg.Addr, up.addr}, up.resolved...) { + if a == "" { + continue + } + st.byAddr[a] = append(st.byAddr[a], up) + } + } + return st +} + +// resolveAddr returns the address to dial for a server plus the bootstrap +// addresses behind it, if the configured host is a name. +func resolveAddr(s config.UpstreamServer, bootstraps map[string][]string, lookup bool) (addr string, resolved []string) { + host, port, err := net.SplitHostPort(s.Addr) + if err != nil { + // Not host:port — hand it to the dialer verbatim. + return s.Addr, nil + } + if net.ParseIP(host) != nil { + return s.Addr, nil + } + if addrs := bootstraps[host]; len(addrs) > 0 { + return net.JoinHostPort(addrs[0], port), addrs + } + if lookup { + ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) + defer cancel() + addrs, err := net.DefaultResolver.LookupHost(ctx, host) + if err == nil && len(addrs) > 0 { + return net.JoinHostPort(addrs[0], port), addrs + } + } + return s.Addr, nil } // Config returns the pool's upstream config. -func (p *Pool) Config() config.Upstream { return p.cfg } +func (p *Pool) Config() config.Upstream { + if st := p.state.Load(); st != nil { + return st.cfg + } + return config.Upstream{} +} // All returns every upstream. -func (p *Pool) All() []*Upstream { return p.ups } +func (p *Pool) All() []*Upstream { return p.state.Load().all() } -// Get returns an upstream by address, or nil. +// Get returns an upstream by configured or resolved address, or nil. func (p *Pool) Get(addr string) *Upstream { - for _, u := range p.ups { + for _, u := range p.All() { if u.addr == addr || u.cfg.Addr == addr { return u } @@ -298,8 +376,9 @@ func (p *Pool) Get(addr string) *Upstream { // Healthy returns upstreams whose breaker is closed, in configured order. func (p *Pool) Healthy() []*Upstream { - out := make([]*Upstream, 0, len(p.ups)) - for _, u := range p.ups { + ups := p.All() + out := make([]*Upstream, 0, len(ups)) + for _, u := range ups { if !u.BreakerOpen() { out = append(out, u) } @@ -309,8 +388,9 @@ func (p *Pool) Healthy() []*Upstream { // Stats snapshots every upstream. func (p *Pool) Stats() []Stats { - out := make([]Stats, 0, len(p.ups)) - for _, u := range p.ups { + ups := p.All() + out := make([]Stats, 0, len(ups)) + for _, u := range ups { out = append(out, u.Stats()) } return out @@ -318,95 +398,54 @@ func (p *Pool) Stats() []Stats { // clientFor returns the memoised dns.Client for an upstream. func (p *Pool) clientFor(u *Upstream) *dns.Client { - p.mu.RLock() - c, ok := p.clients[u] - p.mu.RUnlock() - if ok { - return c + if c, ok := p.clients.Load(u); ok { + return c.(*dns.Client) } - var nc *dns.Client + nc := &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second} switch u.cfg.Proto { case "tls": - tlsCfg := &tls.Config{ - ServerName: u.cfg.ServerName, - InsecureSkipVerify: u.cfg.InsecureSkipVerify, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - } nc = &dns.Client{ - Net: "tcp-tls", - TLSConfig: tlsCfg, - Dialer: &net.Dialer{Timeout: 3 * time.Second}, - Timeout: 5 * time.Second, + Net: "tcp-tls", + TLSConfig: &tls.Config{ + ServerName: u.cfg.ServerName, + InsecureSkipVerify: u.cfg.InsecureSkipVerify, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"dot"}, + }, + Dialer: &net.Dialer{Timeout: 3 * time.Second}, + Timeout: 5 * time.Second, } case "tcp": nc = &dns.Client{Net: "tcp", Timeout: 5 * time.Second} - default: - nc = &dns.Client{Net: "udp", UDPSize: 1232, Timeout: 3 * time.Second} - } - p.mu.Lock() - p.clients[u] = nc - p.mu.Unlock() - return nc -} - -// selectFor returns the upstreams to try, honouring the policy. tags, if -// non-empty, restricts the pool to a group's own upstreams. -func (p *Pool) selectFor(policy string, maxAttempts int) []*Upstream { - healthy := p.Healthy() - if len(healthy) == 0 { - // Every breaker is open: try them all anyway rather than failing the - // query outright. A blip should not become an outage. - healthy = append(healthy, p.ups...) - } - if len(healthy) == 0 { - return nil - } - if maxAttempts <= 0 || maxAttempts > len(healthy) { - maxAttempts = len(healthy) - } - switch policy { - case "failover": - // Configured order, and rotate past the unhealthy ones. - return healthy[:maxAttempts] - case "random": - p.mu2.Lock() - p.rng.Shuffle(len(healthy), func(i, j int) { healthy[i], healthy[j] = healthy[j], healthy[i] }) - p.mu2.Unlock() - return healthy[:maxAttempts] - default: // parallel - return healthy } + actual, _ := p.clients.LoadOrStore(u, nc) + return actual.(*dns.Client) } // Exchange forwards q to the pool and returns the first acceptable answer. // -// policy is the pool's configured policy; tags restricts the pool to a -// per-client upstream override. edns0Size is the client's advertised buffer, -// used to decide TCP fallback on truncation. -func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout time.Duration) (*dns.Msg, string, error) { - candidates := p.ups - if len(tags) > 0 { - if tagged := p.byTag[strings.Join(tags, ",")]; len(tagged) > 0 { - candidates = tagged - } +// want restricts the query to the upstreams an ACL group named, by configured +// or resolved address; an empty list means the whole pool. timeout bounds one +// attempt. +func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, want []string, timeout time.Duration) (*dns.Msg, string, error) { + st := p.state.Load() + candidates := st.all() + if len(want) > 0 { + candidates = p.restrict(st, want) } if len(candidates) == 0 { return nil, "", ErrNoUpstream } - attempts := p.cfg.MaxAttempts - chosen := selectFrom(candidates, p.cfg.Policy, attempts) + chosen := p.pick(st, candidates, st.cfg.MaxAttempts) if len(chosen) == 0 { return nil, "", ErrNoUpstream } - if len(chosen) == 1 { - msg, up, err := p.exchangeOne(ctx, chosen[0], q, timeout) - return msg, up, err + return p.exchangeOne(ctx, st.cfg, chosen[0], q, timeout) } - // Parallel: send to all at once, take the first answer that is not a - // transport failure. Cancellable so we do not leak requests to a slow + // Parallel: send to all at once and take the first real answer. The + // context is cancelled on return so we do not leak a request to a slow // upstream after another one has answered. type res struct { msg *dns.Msg @@ -418,7 +457,7 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout defer cancel() for _, up := range chosen { go func(up *Upstream) { - msg, addr, err := p.exchangeOne(sub, up, q, timeout) + msg, addr, err := p.exchangeOne(sub, st.cfg, up, q, timeout) results <- res{msg: msg, up: addr, err: err} }(up) } @@ -426,13 +465,13 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout for i := 0; i < len(chosen); i++ { select { case r := <-results: - if r.err == nil && r.msg != nil && !isTransportFailure(r.msg.Rcode) { - return r.msg, r.up, nil - } - if r.err != nil { + switch { + case r.err != nil: lastErr = r.err - } else if r.msg != nil { - lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode]) + case r.msg != nil && r.msg.Rcode != dns.RcodeServerFailure: + return r.msg, r.up, nil + default: + lastErr = fmt.Errorf("upstream %s returned SERVFAIL", r.up) } case <-ctx.Done(): return nil, "", ctx.Err() @@ -444,8 +483,29 @@ func (p *Pool) Exchange(ctx context.Context, q *dns.Msg, tags []string, timeout return nil, "", lastErr } -func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { - // Filter out open breakers but keep everything if that empties the list. +// restrict resolves an ACL group's upstream list to upstreams, keeping the +// configured order and dropping names that match nothing. When nothing in the +// list resolves the full pool is used, so a typo cannot black-hole a client. +func (p *Pool) restrict(st *poolState, want []string) []*Upstream { + seen := make(map[*Upstream]bool, len(want)) + out := make([]*Upstream, 0, len(want)) + for _, name := range want { + for _, up := range st.byAddr[name] { + if !seen[up] { + seen[up] = true + out = append(out, up) + } + } + } + if len(out) == 0 { + return st.all() + } + return out +} + +// pick filters out open breakers (keeping everything if that empties the +// list) and applies the pool policy. +func (p *Pool) pick(st *poolState, ups []*Upstream, maxAttempts int) []*Upstream { open := make([]*Upstream, 0, len(ups)) for _, u := range ups { if !u.BreakerOpen() { @@ -458,11 +518,13 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { if maxAttempts <= 0 || maxAttempts > len(open) { maxAttempts = len(open) } - switch policy { + switch st.cfg.Policy { case "failover": return open[:maxAttempts] case "random": - rand.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] }) + p.rngMu.Lock() + defer p.rngMu.Unlock() + p.rng.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] }) return open[:maxAttempts] default: return open @@ -471,63 +533,54 @@ func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream { // exchangeOne sends q to a single upstream, retrying over TCP when the UDP // answer comes back truncated (RFC 1035 §4.2.1). -func (p *Pool) exchangeOne(ctx context.Context, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) { +func (p *Pool) exchangeOne(ctx context.Context, cfg config.Upstream, up *Upstream, q *dns.Msg, timeout time.Duration) (*dns.Msg, string, error) { if timeout <= 0 { timeout = 2 * time.Second } client := p.clientFor(up) - msg := q.Copy() - start := time.Now() - resp, rtt, err := client.ExchangeContext(ctx, msg, up.addr) - up.Success(rtt) + resp, rtt, err := client.ExchangeContext(ctx, q.Copy(), up.addr) if err != nil { + up.Failure(err, thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D()) return nil, up.addr, err } - _ = start + up.Success(rtt) if !resp.Truncated { return resp, up.addr, nil } - // Retry over TCP to get the full answer. Some upstreams are configured - // UDP-only in which case this simply fails and we use the TC answer. + // Retry over TCP for the full answer. An upstream that is UDP-only just + // fails here and we return the truncated answer; the client retries over + // TCP itself. tcpClient := &dns.Client{Net: "tcp", Timeout: timeout} resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr) - if err2 == nil && resp2 != nil && !resp2.Truncated { - up.Success(rtt2) - return resp2, up.addr, nil + if err2 != nil || resp2 == nil || resp2.Truncated { + if err2 == nil { + err2 = errors.New("still truncated over TCP") + } + up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), + thresholdFor(up, cfg.BreakerThreshold), cfg.BreakerCooldown.D()) + return resp, up.addr, nil } - up.Failure(fmt.Errorf("truncated answer, TCP retry failed: %w", err2), up.breakerThreshold(), p.cfg.BreakerCooldown.D()) - // Return the truncated answer; the client will retry over TCP itself. - return resp, up.addr, nil -} - -func isTransportFailure(rcode int) bool { - return rcode == dns.RcodeServerFailure -} - -// breakerThreshold is the effective failure threshold for an upstream. -func (u *Upstream) breakerThreshold() int { - if u.cfg.MaxFails > 0 { - return u.cfg.MaxFails - } - return 0 + up.Success(rtt2) + return resp2, up.addr, nil } // HealthCheck probes every upstream with a cheap query, refreshing health // state even when no client traffic is flowing. It returns the number of // upstreams that answered. func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int { + st := p.state.Load() probe := new(dns.Msg) probe.SetQuestion(".", dns.TypeNS) probe.RecursionDesired = true ok := 0 - for _, up := range p.ups { + for _, up := range st.all() { if up.BreakerOpen() { continue } c := p.clientFor(up) _, rtt, err := c.ExchangeContext(ctx, probe, up.addr) if err != nil { - up.Failure(err, p.breakerThresholdFor(up), p.cfg.BreakerCooldown.D()) + up.Failure(err, thresholdFor(up, st.cfg.BreakerThreshold), st.cfg.BreakerCooldown.D()) continue } up.Success(rtt) @@ -536,63 +589,47 @@ func (p *Pool) HealthCheck(ctx context.Context, timeout time.Duration) int { return ok } -func (p *Pool) breakerThresholdFor(u *Upstream) int { - if t := u.breakerThreshold(); t > 0 { - return t +func thresholdFor(up *Upstream, def int) int { + if up.cfg.MaxFails > 0 { + return up.cfg.MaxFails } - return p.cfg.BreakerThreshold + return def } -// Rebind applies a new upstream configuration in place, keeping the live -// health state of upstreams whose address is unchanged. +// Rebind applies a new upstream configuration, keeping the live health state +// of upstreams whose address and protocol are unchanged. +// +// It builds a whole new immutable state and swaps it in with a single atomic +// store: queries already in flight finish against the state they started +// with, and the next query sees the new one. There is no window in which half +// the upstreams are old and half new. func (p *Pool) Rebind(cfg config.Upstream) { p.mu.Lock() defer p.mu.Unlock() - p.cfg = cfg - old := make(map[string]*Upstream, len(p.ups)) - for _, u := range p.ups { - old[u.cfg.Addr+"/"+u.cfg.Proto] = u + old := p.state.Load() + next := buildState(cfg, old, true) + p.state.Store(next) + // Drop the memoised clients of upstreams that are gone; their shells are + // only reachable from in-flight queries, which let them go on their own. + live := make(map[*Upstream]bool, len(next.all())) + for _, u := range next.all() { + live[u] = true } - next := make([]*Upstream, 0, len(cfg.Servers)) - for _, s := range cfg.Servers { - if u, ok := old[s.Addr+"/"+s.Proto]; ok { - u.cfg = s - next = append(next, u) - delete(old, s.Addr+"/"+s.Proto) - continue - } - u := &Upstream{cfg: s} - u.setHealth(HealthUnknown) - next = append(next, u) - } - p.ups = next - // Drop clients for removed upstreams. - for u := range p.clients { - if old[u.cfg.Addr+"/"+u.cfg.Proto] != nil { - delete(p.clients, u) + for _, u := range old.all() { + if !live[u] { + p.clients.Delete(u) } } - p.resolveAllLocked() } -func (p *Pool) resolveAllLocked() { - for _, b := range p.cfg.Bootstraps { - p.bootstrap[b.Name] = append([]string(nil), b.Addresses...) - } - for _, up := range p.ups { - host, port, err := net.SplitHostPort(up.cfg.Addr) - if err != nil { - continue - } - if net.ParseIP(host) != nil { - up.addr = up.cfg.Addr - continue - } - if addrs := p.bootstrap[host]; len(addrs) > 0 { - up.resolved = addrs - up.addr = net.JoinHostPort(addrs[0], port) - continue - } - up.addr = up.cfg.Addr +// String renders the pool for logs. +func (p *Pool) String() string { + ups := p.All() + addrs := make([]string, 0, len(ups)) + for _, u := range ups { + addrs = append(addrs, u.addr) } + return "pool[" + strings.Join(addrs, " ") + "]" } + +func ptr[T any](v T) *T { return &v } diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index b671cb9..c4be9a9 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -1,3 +1,14 @@ -fancy-dns -

fancy-dns

-

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

+ + + + + + + web + + + + +
+ + diff --git a/internal/zonefile/zonefile_test.go b/internal/zonefile/zonefile_test.go new file mode 100644 index 0000000..34a0c6b --- /dev/null +++ b/internal/zonefile/zonefile_test.go @@ -0,0 +1,806 @@ +package zonefile + +import ( + "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 { + 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()) + } + out = append(out, fmt.Sprintf("%d %s %d %s %s %s", + rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4])) + } + return out +} + +func checkSummary(t *testing.T, z *Zone, want []string) { + t.Helper() + got := summary(t, z) + if !slices.Equal(got, want) { + t.Errorf("records mismatch\n got: %q\nwant: %q", got, want) + } +} + +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 sign", 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 keeps 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 +@ IN SOA ns1.example.com. hostmaster.example.com. ( + 2024010101 ; serial + 7200 ; refresh + 3600 ; retry + 1209600 ; expire + 300 ) ; minimum +@ IN NS ns1.example.com. +www IN A 192.0.2.1 +www 600 IN A 192.0.2.2 + AAAA 2001:db8::1 +alias IN CNAME www.example.com. +mail IN MX 10 mx1.example.com. +note IN TXT "hello ; world @ example" +*.wild 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 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") + } + checkSummary(t, z, []string{ + `3 example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`, + `9 example.com. 3600 IN NS ns1.example.com.`, + `10 www.example.com. 3600 IN A 192.0.2.1`, + `11 www.example.com. 600 IN A 192.0.2.2`, + `12 www.example.com. 3600 IN AAAA 2001:db8::1`, + `13 alias.example.com. 3600 IN CNAME www.example.com.`, + `14 mail.example.com. 3600 IN MX 10 mx1.example.com.`, + `15 note.example.com. 3600 IN TXT "hello ; world @ example"`, + `16 *.wild.example.com. 3600 IN A 192.0.2.3`, + `17 198.51.100.5. 3600 IN A 198.51.100.5`, + `18 low.example.com. 3600 IN A 192.0.2.7`, + `19 mixed.example.com. 600 IN A 192.0.2.8`, + `20 cls.example.com. 3600 IN A 192.0.2.9`, + `21 at.example.com. 3600 IN TXT "@"`, + }) + 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 z.DefaultTTL != 3600 { + t.Errorf("DefaultTTL = %d, want 3600 ($TTL 1h)", z.DefaultTTL) + } + if z.MinTTL != 600 { + t.Errorf("MinTTL = %d, want 600 (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 is not lowercased", rec.Name) + } + if !strings.HasSuffix(rec.Name, ".") { + t.Errorf("record name %q is not fully qualified", rec.Name) + } + if rec.File != "full.zone" { + t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File) + } + } +} + +func TestParseTTLInheritance(t *testing.T) { + z := mustParse(t, `$ORIGIN example.com. +$TTL 1h +one IN A 192.0.2.1 +two 300 IN A 192.0.2.2 +three IN A 192.0.2.3 +$TTL 2h +four IN A 192.0.2.4 +`, Options{}) + checkSummary(t, z, []string{ + `2 one.example.com. 3600 IN A 192.0.2.1`, + `3 two.example.com. 300 IN A 192.0.2.2`, + `4 three.example.com. 3600 IN A 192.0.2.3`, + `6 four.example.com. 7200 IN A 192.0.2.4`, + }) + if z.DefaultTTL != 7200 { + t.Errorf("DefaultTTL = %d, want 7200", 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 len(z.Records) != 1 || z.Records[0].TTL != 42 { + t.Fatalf("records = %v, want one record with TTL 42", summary(t, z)) + } + if z.DefaultTTL != 42 { + t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL) + } + }) + + t.Run("no ttl source leaves zero", func(t *testing.T) { + z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."}) + if len(z.Records) != 1 || z.Records[0].TTL != 0 { + t.Fatalf("records = %v, want one record with TTL 0", summary(t, z)) + } + if z.MinTTL != 0 { + t.Errorf("MinTTL = %d, want 0", z.MinTTL) + } + }) + + t.Run("soa ttl is 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) + } + if z.Records[1].TTL != 300 { + t.Errorf("records = %v, want 300 inherited from the SOA", summary(t, z)) + } + }) + + t.Run("soa minttl 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."}) + if z.Records[1].TTL != 7 { + t.Errorf("records = %v, want the SOA minimum 7", summary(t, z)) + } + }) +} + +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"}, + }, + { + name: "omitted owner repeats the previous one", + src: "one IN A 192.0.2.1\n IN A 192.0.2.2\n IN 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 IN A 192.0.2.1\n", + want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"}, + }, + { + name: "wildcard owner is expanded but keeps the star", + src: "*.sub IN A 192.0.2.1\n", + want: []string{"*.sub.origin.example. 192.0.2.1"}, + }, + { + name: "uppercase owner is lowercased", + src: "WWW IN A 192.0.2.1\n", + want: []string{"www.origin.example. 192.0.2.1"}, + }, + } + 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 TestParseParenthesised(t *testing.T) { + t.Run("multi line rdata", 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.Minttl != 300 { + t.Errorf("SOA.Minttl = %d, want 300", z.SOA.Minttl) + } + 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 := summary(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) { + t.Errorf("record = %q, want a two-string TXT", got) + } + }) + + t.Run("rdata on one line with parens", func(t *testing.T) { + z := mustParse(t, "host MX ( 10 mx1.example.com. 20 mx2.example.com. )\n", Options{Origin: "example.com."}) + if len(z.Records) != 1 { + t.Fatalf("got %d records, want 1", len(z.Records)) + } + if got := summary(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com. 20 mx2.example.com.") { + t.Errorf("record = %q, want two MX rdata fields", got) + } + }) +} + +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("$ORIGIN without a name is an error", func(t *testing.T) { + // BIND resets to the zone origin when $ORIGIN carries no argument; + // this parser rejects it instead. Documented as current behaviour. + 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)) + } + 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 IN A 192.0.2.1 ; trailing comment + + +two IN A 192.0.2.2 ;comment without space +three IN TXT "semi ; colon" ; real comment +`, Options{Origin: "example.com."}) + if len(z.Records) != 3 { + t.Fatalf("got %d records, want 3: %v", len(z.Records), summary(t, z)) + } + if !strings.Contains(summary(t, z)[2], `"semi ; colon"`) { + t.Errorf("record = %q, want the quoted semicolon preserved", summary(t, z)[2]) + } + for _, rec := range z.Records { + if rec.RR.Header().Ttl != 3600 { + t.Errorf("%q TTL = %d, want 3600", rec.Text(), rec.RR.Header().Ttl) + } + } + checkSummary(t, z, []string{ + `6 one.example.com. 3600 IN A 192.0.2.1`, + `9 two.example.com. 3600 IN A 192.0.2.2`, + `10 three.example.com. 3600 IN TXT "semi ; colon"`, + }) +} + +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", wantSub: ""}, + {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", wantSub: ""}, + {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:"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + 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) + } + }) + } +} + +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 _, 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."}, + {"example.com.zone", "example.com."}, + {"/var/lib/fancy-dns/example.com.zone", "example.com."}, + {"plain", "plain."}, + {".hidden", ".hidden."}, + } + 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."}, + } + 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 \`}, + {"no comment here", "no comment here"}, + } + 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"}}, + } + for _, tc := range tests { + if got := splitFields(tc.in); !slices.Equal(got, tc.want) { + t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestDeparen(t *testing.T) { + tests := []struct{ in, want string }{ + {"a ( b", "a b"}, + {"a ) b", "a b"}, + {"a b", "a b"}, + } + for _, tc := range tests { + if got := deparen(tc.in); got != tc.want { + t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestTokenClassification(t *testing.T) { + for _, s := range []string{"0", "300", "1h30m", "300s"} { + if !isTTLToken(s) { + t.Errorf("isTTLToken(%q) = false, want true", s) + } + } + for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} { + if isTTLToken(s) { + t.Errorf("isTTLToken(%q) = true, want false", s) + } + } + for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} { + if !isClassToken(s) { + t.Errorf("isClassToken(%q) = false, want true", s) + } + } + for _, s := range []string{"", "300", "ANY", "cs", "in4"} { + if isClassToken(s) { + t.Errorf("isClassToken(%q) = true, want false", s) + } + } + for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} { + if !isRRTYPE(s) { + t.Errorf("isRRTYPE(%q) = false, want true", s) + } + } + for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} { + if isRRTYPE(s) { + t.Errorf("isRRTYPE(%q) = true, want false", s) + } + } +} + +func TestCloneSOA(t *testing.T) { + if cloneSOA(nil) != nil { + t.Error("cloneSOA(nil) != nil") + } + in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60} + out := cloneSOA(in) + if out == in { + t.Fatal("cloneSOA returned the same pointer") + } + if out.Serial != 7 || out.Minttl != 60 { + t.Errorf("cloneSOA copied %+v", out) + } +} + +func TestWhereAndDirOf(t *testing.T) { + if got := where(""); got != "zone" { + t.Errorf("where(\"\") = %q, want zone", got) + } + if got := where("a.zone"); got != "a.zone" { + t.Errorf("where(a.zone) = %q", got) + } + if got := dirOf("a.zone"); got != "." { + t.Errorf("dirOf(a.zone) = %q, want .", got) + } + if got := dirOf("/x/y/a.zone"); got != "/x/y" { + t.Errorf("dirOf(/x/y/a.zone) = %q", got) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9ca8e8d..17dda61 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,122 +1,84 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +/** + * App — routes, the shell and the theme bootstrap. + * + * The daemon serves the console and the API from one origin, so a real + * BrowserRouter works as long as the Go handler falls back to index.html (it + * does). `useThemeInit` runs the stored theme onto `` before the first + * paint; `ThemeToggle` inside AppShell keeps it in sync afterwards. + */ -function App() { - const [count, setCount] = useState(0) +import { useEffect } from 'react' +import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { ToastProvider } from './components/ui' +import { AppShell } from './components/layout' +import Blocklists from './pages/Blocklists.tsx' +import Cache from './pages/Cache.tsx' +import Clients from './pages/Clients.tsx' +import Dashboard from './pages/Dashboard.tsx' +import Domains from './pages/Domains.tsx' +import Login from './pages/Login.tsx' +import Logs from './pages/Logs.tsx' +import NotFound from './pages/NotFound.tsx' +import Settings from './pages/Settings.tsx' +import Upstreams from './pages/Upstreams.tsx' + +const THEME_KEY = 'fancydns.theme' + +/** Applies the stored theme to `` before anything paints. */ +function useThemeInit(): void { + useEffect(() => { + if (typeof document === 'undefined') return + if (document.documentElement.dataset.theme !== undefined) return + let stored: string | null = null + try { + stored = localStorage.getItem(THEME_KEY) + } catch { + // Storage disabled: fall back to the system preference. + stored = null + } + if (stored === 'light' || stored === 'dark') { + document.documentElement.dataset.theme = stored + } + }, []) +} + +/** Everything that lives inside the shell. */ +function AppRoutes() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

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

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) } -export default App +export default function App() { + useThemeInit() + + return ( + + + + {/* Bare: no shell, no header, no nav. */} + } /> + + + + } + /> + + + + ) +} diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 902c1ff..ed996b0 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -27,7 +27,9 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< fnRef.current = fn useEffect(() => { - const controller = new AbortController() + // The fetcher takes no AbortSignal, so cancellation is a guard: a response + // that arrives after an unmount or a newer request is dropped on the floor + // instead of overwriting fresher state. let live = true setLoading(true) @@ -50,7 +52,6 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< return () => { live = false - controller.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, nonce]) diff --git a/web/src/index.css b/web/src/index.css index f658a10..f2d33de 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -198,12 +198,6 @@ color: var(--fg); } - /* Numeric columns must not jitter as digits change. */ - .tabular { - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum" 1; - } - * { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; @@ -244,3 +238,9 @@ } } } + +/** Numeric columns must not jitter as their digits change. */ +@utility tabular { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 119fc4b..e227aa3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -417,6 +417,8 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { let backoff = RECONNECT_MIN_MS let timer: ReturnType | undefined let attempt = 0 + // One error report per outage streak: a flaky link must not toast every 15s. + let reported = false const open = (): void => { if (stopped) return @@ -447,11 +449,15 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { const message = res.ok ? 'the live log stream is not supported by this browser' : `the live log stream failed with HTTP ${res.status}` - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() return } + reported = false attempt = 0 backoff = RECONNECT_MIN_MS handlers.onOpen() @@ -463,7 +469,10 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { if (stopped) return if (controller.signal.aborted) return const message = err instanceof Error ? err.message : 'the live log stream dropped' - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() } } diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..aa2970d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,9 +1,19 @@ +/** + * Entry point. The stylesheet is imported first so Tailwind's layers are in + * place before the first React render commits anything. + */ + import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -createRoot(document.getElementById('root')!).render( +const container = document.getElementById('root') +if (container === null) { + throw new Error('fancy-dns: #root is missing from index.html') +} + +createRoot(container).render( , diff --git a/web/src/pages/Cache.tsx b/web/src/pages/Cache.tsx new file mode 100644 index 0000000..19a55ca --- /dev/null +++ b/web/src/pages/Cache.tsx @@ -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 + +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 & { + 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 ( +
+
    + {payload.map((item) => ( +
  • +
  • + ))} +
+
+ ) +} + +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 => { + 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 + + 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: ( + + {entry.name} + + ), + qtype: {entry.qtype || '—'}, + count: {formatNumber(entry.count)}, + hits: {formatNumber(entry.hits)}, + last_seen: {formatRelative(entry.last_seen)}, + })) + + return ( +
+ setConfirmFlush(true)} + loading={flush.pending} + > + Flush cache + + } + /> + +
+ + + + + + +
+ +
+ + +
+
+ + + + {pie.map((slice) => ( + + ))} + + } /> + + +
+
+

{formatRate(hitRate, 0)}

+

hit rate

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

+ {persistMessage} +

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

{topError}

+
+ ) : ( +
+ String(row.__key)} + loading={topLoading && entries.length === 0} + empty={ + + } + /> + + )} + + + setConfirmFlush(false)} + onConfirm={() => void onFlush()} + title="Flush the cache?" + message="Every cached answer is dropped. The next query for each name goes back to the upstreams, so expect a burst of upstream traffic." + confirmLabel="Flush cache" + destructive + pending={flush.pending} + /> + + ) +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 864c4a8..df9b60c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -23,11 +23,11 @@ import { } from 'recharts' import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react' -import { getDashboard } from '../../lib/api' -import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types' -import { useApi } from '../../hooks/useApi' -import { useAutoRefresh } from '../../hooks/useAutoRefresh' -import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange' +import { getDashboard } from '../lib/api' +import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../lib/types' +import { useApi } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange' import { formatClock, formatCompact, @@ -36,7 +36,7 @@ import { formatPercent, formatRate, formatUptime, -} from '../../lib/format' +} from '../lib/format' import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui' import { HealthPill, PageError, PageHeader } from '../components/layout' @@ -47,7 +47,6 @@ const QUERIES = '#38bdf8' const CACHED = '#34d399' const BLOCKED = '#f59e0b' const ERRORS = '#f87171' -const TIP_BG = '#0f172a' /** The parts of the dashboard payload that older daemons may not send. */ type DashboardView = Partial & { @@ -64,20 +63,6 @@ function view(data: DashboardData | null): DashboardView { return (data ?? {}) as DashboardView } -/** `useToast()` shape is the UI kit's business; this just needs a call. */ -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) -} - type TipProps = { active?: boolean label?: number | string @@ -222,7 +207,7 @@ export default function Dashboard() { + + + + +

+

+ + {pending && ( +

+ + waiting for the node +

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