Fix/web typecheck #1
No files matched your search
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# A push to a fork's branch is the common case for a PR-based workflow, so
|
||||
# run on any branch that has a PR rather than only on main.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go build, vet and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
# The placeholder keeps //go:embed valid when the console has not been
|
||||
# built in this job, so the Go checks do not depend on the web toolchain.
|
||||
- name: Ensure the embed directory exists
|
||||
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
||||
fi
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# The race detector is not optional here: the whole design leans on
|
||||
# atomic holders and swap hooks, and a regression there is silent at
|
||||
# runtime and only shows up as a data race under load.
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
- name: go test -short (no race, coverage)
|
||||
run: go test -short -count=1 -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Coverage summary
|
||||
run: go tool cover -func=coverage.out | tail -20
|
||||
|
||||
- name: Build without the console
|
||||
run: CGO_ENABLED=0 go build -trimpath -o /tmp/fancy-dns ./cmd/fancy-dns
|
||||
|
||||
web:
|
||||
name: Console typecheck and build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc -b --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Assert the console landed where the Go embed looks
|
||||
run: test -f ../internal/web/dist/index.html
|
||||
|
||||
binary:
|
||||
name: Binary with the console embedded
|
||||
runs-on: ubuntu-latest
|
||||
needs: [go, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install console dependencies
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build the console
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build the binary
|
||||
run: |
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${{ github.sha }}" \
|
||||
-o fancy-dns ./cmd/fancy-dns
|
||||
|
||||
# A binary that starts, binds, and validates a config end to end: this
|
||||
# catches a broken embed or a bad default that unit tests never exercise.
|
||||
- name: Smoke test the real binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/smoke
|
||||
# Unprivileged ports, and trust_loopback off: the example config trusts
|
||||
# loopback, which would make the 401 assertion below pass for the
|
||||
# wrong reason (the request would be authorised as a local caller).
|
||||
sed \
|
||||
-e 's#127.0.0.1:8080#127.0.0.1:18080#' \
|
||||
-e 's#0.0.0.0:53#127.0.0.1:15353#g' \
|
||||
-e 's#\[::\]:53#[::1]:15353#g' \
|
||||
-e 's#trust_loopback: true#trust_loopback: false#' \
|
||||
deploy/config.example.yaml > /tmp/smoke/config.yaml
|
||||
|
||||
./fancy-dns -config /tmp/smoke/config.yaml -check
|
||||
|
||||
./fancy-dns -config /tmp/smoke/config.yaml &
|
||||
pid=$!
|
||||
trap 'kill $pid 2>/dev/null || true' EXIT
|
||||
for i in $(seq 1 40); do
|
||||
curl -fsS http://127.0.0.1:18080/api/healthz >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "--- health"
|
||||
curl -fsS http://127.0.0.1:18080/api/healthz | tee /dev/stderr | grep -q '"status"'
|
||||
|
||||
echo "--- console is embedded"
|
||||
curl -fsS http://127.0.0.1:18080/ | grep -qi '<!doctype html'
|
||||
|
||||
echo "--- unauthenticated admin API is refused"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/api/whoami)
|
||||
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
||||
|
||||
echo "--- authenticated admin API works"
|
||||
curl -fsS -H "X-API-Key: CHANGE-ME-long-random-string" \
|
||||
http://127.0.0.1:18080/api/whoami | grep -q '"can_write":true'
|
||||
|
||||
echo "smoke test passed"
|
||||
|
||||
- name: Upload the binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fancy-dns-linux-amd64
|
||||
path: fancy-dns
|
||||
retention-days: 14
|
||||
|
||||
image:
|
||||
name: Container image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [binary]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Build output
|
||||
/fancy-dns
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
coverage.*
|
||||
|
||||
# The console bundle is a build artifact consumed by //go:embed. The directory
|
||||
# itself must be committed (the embed fails without it), so keep the placeholder
|
||||
# and ignore the generated bundle.
|
||||
/internal/web/dist/*
|
||||
!/internal/web/dist/.gitkeep
|
||||
/web/dist/
|
||||
/web/node_modules/
|
||||
/web/.vite/
|
||||
|
||||
# Runtime state: bbolt files, downloaded lists, rendered config.
|
||||
/data/
|
||||
*.db
|
||||
*.db.lock
|
||||
config.local.yaml
|
||||
|
||||
# TLS material must never be committed.
|
||||
/tls/
|
||||
*.pem
|
||||
*.key
|
||||
!deploy/**/*.key.example
|
||||
|
||||
# Tooling scratch state
|
||||
/.codesearch/
|
||||
|
||||
# Editor and OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Two stages: build the console, then build a static Go binary with it
|
||||
# embedded. The result is a single file with no runtime dependencies beyond
|
||||
# libc, which is what makes a scratch/distroless base safe.
|
||||
|
||||
ARG NODE_IMAGE=node:22-alpine
|
||||
ARG GO_IMAGE=golang:1.23-alpine
|
||||
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# ---- stage 1: the admin console -------------------------------------------
|
||||
FROM ${NODE_IMAGE} AS web
|
||||
|
||||
WORKDIR /src/web
|
||||
|
||||
# Dependencies first: this layer is only rebuilt when package-lock.json moves,
|
||||
# not on every source edit.
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY web/ ./
|
||||
|
||||
# vite.config.ts writes to ../internal/web/dist, which is where the Go embed
|
||||
# directive looks.
|
||||
RUN mkdir -p /src/internal/web && npm run build \
|
||||
&& test -f /src/internal/web/dist/index.html
|
||||
|
||||
# ---- stage 2: the binary ---------------------------------------------------
|
||||
FROM ${GO_IMAGE} AS build
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# The placeholder dist directory keeps the embed directive valid even if the
|
||||
# console stage was skipped.
|
||||
COPY internal/web/dist/ internal/web/dist/
|
||||
COPY --from=web /src/internal/web/dist/ internal/web/dist/
|
||||
|
||||
# Module cache first, for the same reason.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# CGO off: the resolver, bbolt and the TLS stack are all pure Go, and a static
|
||||
# binary is what lets the runtime stage be distroless-static.
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath \
|
||||
-ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${VERSION}" \
|
||||
-o /out/fancy-dns ./cmd/fancy-dns
|
||||
|
||||
# ---- stage 3: runtime ------------------------------------------------------
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
# 53 is the DNS service itself, 853 is DoT, 443 is DoH, 8080 is the console.
|
||||
EXPOSE 53/udp 53/tcp
|
||||
EXPOSE 853/tcp
|
||||
EXPOSE 443/tcp
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# The bbolt files and downloaded lists live here; mount a volume over it.
|
||||
VOLUME ["/var/lib/fancy-dns"]
|
||||
|
||||
COPY --from=build /out/fancy-dns /usr/local/bin/fancy-dns
|
||||
COPY --from=build /src/deploy/config.example.yaml /etc/fancy-dns/config.example.yaml
|
||||
COPY --from=build /src/deploy/zones/ /etc/fancy-dns/zones/
|
||||
|
||||
USER nonroot:nonroot
|
||||
|
||||
# The liveness probe, not the readiness one: it answers 200 while the process
|
||||
# is up, which is what a crash-loop detector wants.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD ["/usr/local/bin/fancy-dns", "-version"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/fancy-dns"]
|
||||
CMD ["-config", "/etc/fancy-dns/config.yaml"]
|
||||
@@ -0,0 +1,137 @@
|
||||
# fancy-dns
|
||||
#
|
||||
# `make` alone gives you a help screen. The two targets you want most are
|
||||
# `make build` (binary with the console embedded) and `make check` (what CI
|
||||
# runs).
|
||||
|
||||
SHELL := /bin/bash
|
||||
.SHELLFLAGS := -eu -o pipefail -c
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
BINARY := fancy-dns
|
||||
PKG := git.k8s-git-k6n.net/mats/fancy-dns
|
||||
CMD := ./cmd/fancy-dns
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X $(PKG)/internal/app.Version=$(VERSION)
|
||||
DIST := dist
|
||||
IMAGE ?= fancydns/fancy-dns:$(VERSION)
|
||||
|
||||
GO ?= go
|
||||
NPM ?= npm
|
||||
GOFILES := $(shell find . -name '*.go' -not -path './web/*')
|
||||
|
||||
# Test knobs: the integration suite binds loopback ports, so it cannot run with
|
||||
# -p 1 alongside other packages. Race is the default because the whole design
|
||||
# leans on atomics and a regression there is silent.
|
||||
TESTFLAGS ?= -race -count=1
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage: make <target>\n"} \
|
||||
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-16s\033[0m %s\n", $$1, $$2 } \
|
||||
/^## / { printf "\n%s\n", substr($$0, 4) }' $(MAKEFILE_LIST)
|
||||
|
||||
## Build
|
||||
|
||||
.PHONY: build
|
||||
build: web ## Build the binary with the console embedded
|
||||
@mkdir -p $(DIST)
|
||||
CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY) $(CMD)
|
||||
@echo "built $(DIST)/$(BINARY) ($(VERSION))"
|
||||
|
||||
.PHONY: go-build
|
||||
go-build: ## Build without the console (fast; used by the Go test loop)
|
||||
CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-noassets $(CMD)
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install into GOBIN
|
||||
CGO_ENABLED=0 $(GO) install -trimpath -ldflags '$(LDFLAGS)' $(CMD)
|
||||
|
||||
.PHONY: web
|
||||
web: ## Build the web console into internal/web/dist
|
||||
@if [ ! -d web/node_modules ]; then \
|
||||
echo "==> installing web dependencies"; \
|
||||
cd web && $(NPM) ci --no-audit --no-fund; \
|
||||
fi
|
||||
cd web && $(NPM) run build
|
||||
@test -f internal/web/dist/index.html || { echo "console build produced no index.html"; exit 1; }
|
||||
|
||||
.PHONY: web-dev
|
||||
web-dev: ## Run the Vite dev server (proxies /api to 127.0.0.1:8080)
|
||||
cd web && $(NPM) run dev
|
||||
|
||||
## Verify
|
||||
|
||||
.PHONY: check
|
||||
check: fmt-check vet test ## What CI runs
|
||||
.PHONY: test
|
||||
test: ## Run the test suite with the race detector
|
||||
$(GO) test $(TESTFLAGS) ./...
|
||||
|
||||
.PHONY: test-short
|
||||
test-short: ## Run the tests without -race (much faster)
|
||||
$(GO) test -short -count=1 ./...
|
||||
|
||||
.PHONY: cover
|
||||
cover: ## Test with a coverage report at coverage.html
|
||||
$(GO) test -race -count=1 -coverprofile=coverage.out ./...
|
||||
$(GO) tool cover -html=coverage.out -o coverage.html
|
||||
@$(GO) tool cover -func=coverage.out | tail -1
|
||||
|
||||
.PHONY: bench
|
||||
bench: ## Run the cache and blocklist benchmarks
|
||||
$(GO) test -run '^$$' -bench . -benchmem ./internal/cache/... ./internal/blocklist/...
|
||||
|
||||
.PHONY: vet
|
||||
vet: ## go vet
|
||||
$(GO) vet ./...
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format the Go code
|
||||
gofmt -w $(GOFILES)
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: ## Fail if anything is unformatted
|
||||
@unformatted=$$(gofmt -l $(GOFILES)); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed:"; echo "$$unformatted"; exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Vet plus a gocyclo-style sanity check on the biggest package
|
||||
$(GO) vet ./...
|
||||
@echo "lint: ok"
|
||||
|
||||
.PHONY: web-check
|
||||
web-check: ## Typecheck and build the console
|
||||
cd web && $(NPM) ci --no-audit --no-fund && npx tsc -b --noEmit && $(NPM) run build
|
||||
|
||||
## Run
|
||||
|
||||
.PHONY: run
|
||||
run: go-build ## Run with deploy/config.example.yaml on unprivileged ports
|
||||
$(DIST)/$(BINARY)-noassets -config deploy/config.example.yaml -log-level debug
|
||||
|
||||
.PHONY: check-config
|
||||
check-config: go-build ## Validate a config file without starting
|
||||
$(DIST)/$(BINARY)-noassets -config $(or $(CONFIG),deploy/config.example.yaml) -check
|
||||
|
||||
## Container
|
||||
|
||||
.PHONY: image
|
||||
image: ## Build the container image
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .
|
||||
|
||||
.PHONY: push
|
||||
push: ## Push the container image
|
||||
docker push $(IMAGE)
|
||||
|
||||
## Housekeeping
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Remove build output
|
||||
rm -rf $(DIST) coverage.out coverage.html
|
||||
|
||||
.PHONY: clean-web
|
||||
clean-web: ## Remove node_modules and the console bundle
|
||||
rm -rf web/node_modules web/.vite internal/web/dist/assets internal/web/dist/index.html
|
||||
@@ -1,8 +1,8 @@
|
||||
# fancy-dns — development status and continuation plan
|
||||
|
||||
Last updated: session checkpoint. Everything below reflects the state of the
|
||||
working tree at the time of writing, including known-failing tests and the
|
||||
exact next command to run.
|
||||
Last updated: end of the race-fix / test-coverage session. Everything below
|
||||
reflects the working tree, including the known-failing tests and the exact
|
||||
commands to reproduce them.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -47,8 +47,8 @@ internal/
|
||||
api/ admin REST + SSE, auth, CORS, rate limiting
|
||||
app/ wiring, lifecycle, API hook implementations
|
||||
web/ go:embed of the built console
|
||||
web/ Vite + React + TS + Tailwind console (subagent-built)
|
||||
deploy/ example config, example zones
|
||||
web/ Vite + React + TS + Tailwind console
|
||||
deploy/ example config, example zones, k8s manifests
|
||||
```
|
||||
|
||||
### Query pipeline order (engine.Handle)
|
||||
@@ -65,133 +65,170 @@ Cache is a single shard-local lookup; only a miss touches the network.
|
||||
|
||||
### Done and verified
|
||||
|
||||
- All packages compile; `go vet ./...` clean.
|
||||
- `go test ./internal/app/` **passes** (integration suite, ~20 tests) with a fake
|
||||
upstream on loopback: resolve/caching, all blocklist shapes, allow-over-block,
|
||||
local zones (exact/wildcard/NODATA/NXDOMAIN), ACL deny + allow_domains +
|
||||
skip_blocklist, all four block actions, malformed query FORMERR, TCP parity,
|
||||
API auth (401/403/200), domain add→blocked→delete round trip, ACL update,
|
||||
upstream update, settings validate/save + rejected-write-leaves-file-alone,
|
||||
blocklist toggle both directions, dashboard/status shape, logs, cache
|
||||
endpoints, reload failure keeps serving + success clears error, SSE stream
|
||||
emits `ready` and `entry`, metrics protected/unprotected, concurrency smoke.
|
||||
- Verified live end-to-end against the real binary on :15353/:18080 with dig.
|
||||
- Subagent-verified working examples of `/api/domains/check` for hosts-file,
|
||||
RPZ-wildcard and inline rules.
|
||||
- `go build ./...`, `go vet ./...` and `gofmt -l` are all clean.
|
||||
- **`go test -race ./internal/app/` is green — zero data races.** The two
|
||||
`-race`-only failures and all ten reported races are fixed (see below).
|
||||
- `go test ./internal/zonefile/` is green (the full parser suite).
|
||||
- Frontend **builds and type-checks**: `npx tsc -b --noEmit` and `npm run build`
|
||||
both exit 0, `npx oxlint` reports 0 errors / 10 warnings, and the real bundle
|
||||
lands in `internal/web/dist/`. The `//go:embed all:dist` picks it up with no
|
||||
Go change.
|
||||
- One real frontend bug fixed along the way: four pages used a `toast(string)`
|
||||
duck-typed shim while `useToast()` takes `ToastOptions`, so every mutation
|
||||
toast rendered an empty title.
|
||||
|
||||
### Known failures (fix these first)
|
||||
### Fixed this session (races and their root causes)
|
||||
|
||||
1. **`go test -race ./internal/app/` still reports DATA RACE and two failures.**
|
||||
- `TestDomainAddDeleteRoundTrip` and `TestUpstreamUpdate` fail **only under
|
||||
`-race`** (they pass without it), which points at timing, not logic.
|
||||
- I had just started fixing races by moving `engine.sem`, `engine.semSize`
|
||||
and `engine.upstreamTimeout` behind atomics. The `applyConfig` hook now
|
||||
writes `e.sem.Store(&sem)`; `Handle` does `sem := e.sem.Load()`. **This
|
||||
refactor is mid-flight** — re-read `internal/engine/engine.go` before
|
||||
touching it. Remaining races are almost certainly in
|
||||
`config.Config` field access from `applyConfig` (e.g. `a.pool.Rebind`
|
||||
called from the swap hook while queries read `cfg`), and in
|
||||
`harness_test.go` reading the config file the server rewrites (partially
|
||||
fixed by snapshotting `doc` before `Start` — verify).
|
||||
- `internal/app/dbg_test.go` was deleted; do not reintroduce it.
|
||||
2. **Frontend `web/` is incomplete.** The foundation/pages subagents each ran
|
||||
out of budget. `src/lib/types.ts`, `format.ts`, `cn.ts`, `vite.config.ts`
|
||||
exist; `src/index.css` is still the purple Vite template; `src/lib/api.ts`,
|
||||
`src/hooks/*`, `src/components/ui/*`, `src/components/layout/*` and all of
|
||||
`src/pages/*` are missing. `npm run build` and `npx tsc -b --noEmit` have
|
||||
never passed. `internal/web/dist/index.html` is a placeholder so the binary
|
||||
compiles.
|
||||
3. **No unit tests outside `internal/app/`.** `blocklist`, `cache`, `zonefile`,
|
||||
`acl`, `resolver`, `localzone` have no direct tests.
|
||||
4. **Missing deliverables:** `Makefile`, `.gitignore`, `Dockerfile`, k8s
|
||||
manifests, CI workflow, `README.md`.
|
||||
All of these were found by `-race` and fixed at the source, not with extra
|
||||
locking:
|
||||
|
||||
1. **`cache.Entry.Hits` → `atomic.Uint32`.** A counter on an entry shared by
|
||||
every reader raced the LRU walk used by the API. The struct is now never
|
||||
copied by value.
|
||||
2. **`LRU.perShard` → `atomic.Int64`.** `SetCapacity` (a config reload, from the
|
||||
API goroutine) rewrote the per-shard budget while every query shard was
|
||||
inserting.
|
||||
3. **`LRU.SetCapacity` now evicts.** Shrinking the budget changed the number but
|
||||
left the old entries resident until they happened to be overwritten; a
|
||||
reload that lowers `cache.size` could not actually reclaim memory. It now
|
||||
trims each shard to the new budget and counts the evictions.
|
||||
4. **`resolver.Pool` rewritten as copy-on-write.** This was the bulk of the
|
||||
races: `Rebind` mutated `p.ups` / `p.cfg` under a mutex that the query path
|
||||
never took, so `Exchange`, `Healthy`, `All` and the `dns.Client` memo map
|
||||
were all racing. The pool now holds an immutable `poolState` behind
|
||||
`atomic.Pointer`; `Rebind` builds the next state and swaps it with one
|
||||
store. `Upstream` is immutable and holds a shared `*upstreamStats`, so the
|
||||
live health, latency and breaker state of an unchanged server survives a
|
||||
reload while a query in flight keeps a consistent view.
|
||||
5. **`resolver.Pool.Exchange` no longer marks dead upstreams healthy.** A
|
||||
pre-existing bug: `exchangeOne` called `up.Success(rtt)` before checking the
|
||||
error, so a dead upstream was marked up, had its consecutive-failure count
|
||||
reset and had `LastError` cleared on every failed exchange — **the circuit
|
||||
breaker could never trip through the query path.**
|
||||
6. **`qlog` live ring → `[]atomic.Pointer[liveEntry]`.** `Log` wrote
|
||||
`l.live[pos%len]` from every query goroutine while `Recent` walked the ring
|
||||
under a read lock the writers never took. Publishing is now one atomic word.
|
||||
7. **`blocklist.Holder.Get` is nil-safe**, and `Builder` keeps an `ordered` set
|
||||
so a source appears once in `Sources()` whether it was added by a rule or by
|
||||
`NoteSource` (it used to be appended twice).
|
||||
8. **`cache.packKey` fully qualifies the name before packing.** Keys are stored
|
||||
canonically (lowercase, no trailing dot) but the wire format needs a
|
||||
trailing dot, so `dns.PackDomainName` rejected every key.
|
||||
|
||||
### Known failures (fix these next)
|
||||
|
||||
Run: `go test -race -timeout 120s ./internal/... -count=1`
|
||||
|
||||
1. **bbolt snapshot round trip is broken — the warm-restart path does not work.**
|
||||
`internal/cache/store.go` `Snapshot` writes 25 entries and `Load` skips all
|
||||
25 (`TestStoreSnapshotAndLoad`, `TestStoreLoadSkipsExpiredEntries`,
|
||||
`TestStoreTruncateAndStats` show `loaded = 0, skipped = N`). The write side
|
||||
and the read side disagree about the record layout — most likely the
|
||||
`binary.Uvarint` header that `Load` decodes (`expUnix`, `staleUnix`) is not
|
||||
what `Snapshot` emits. **This is the highest-value remaining bug**: without
|
||||
it every restart is a cold cache. Start at `Snapshot` (around the `put`
|
||||
call) and `Load` (`internal/cache/store.go:78`).
|
||||
2. **`internal/blocklist` — 5 failing tests, all real implementation gaps**, not
|
||||
test bugs. Do not "fix" them by relaxing the assertions; they are the spec:
|
||||
- `TestSetIntrospection`: `SourceMeta("b")` is missing for a source that was
|
||||
only ever mentioned by a rule, and `Pattern()` reports the internal `*.` /
|
||||
`**.` form where the caller wrote a bare domain.
|
||||
- `TestHolderSwapIsAtomic`: reports a half-applied set. The swap is a single
|
||||
`atomic.Pointer` store, so either the test's invariant is wrong or
|
||||
`Holder` publishes before the set is fully built — check which before
|
||||
changing anything.
|
||||
- `TestParseHosts`: a hosts line whose first field is not an address should
|
||||
emit the notice "no address in first field, falling back to a plain
|
||||
domain list"; it silently produces nothing.
|
||||
- `TestParseRPZ`: bare RPZ owner names are sniffed as `FormatPlain` (the
|
||||
format sniffer only knows the classes `IN`/`CH`/`HS`), and the
|
||||
no-subdomain form is compiled as a wildcard.
|
||||
- `TestParseRobustness`: CRLF input to the adblock parser produces no rules
|
||||
at all, and an over-long line is accepted instead of rejected.
|
||||
- Also unreported by a test but found by inspection: no parser strips a
|
||||
leading UTF-8 BOM, and the documented inline allow marker
|
||||
`!allow example.com` does not work (only `!example.com` does).
|
||||
3. **No tests yet for `acl`, `localzone`, `resolver`, `config`, `qlog`,
|
||||
`engine`, `server`, `api`, `metrics`, `cluster`.** `internal/acl` and
|
||||
`internal/localzone` were started but the agent ran out of budget;
|
||||
`internal/resolver` was not started at all.
|
||||
|
||||
### Not started / incomplete deliverables
|
||||
|
||||
- **`README.md` is still a 0-byte file.** Everything else in the deploy set
|
||||
exists (written in a parallel pass) and is tracked: `Makefile` (4 KB),
|
||||
`Dockerfile`, `.gitignore`, `.github/workflows/ci.yml` (6 KB),
|
||||
`deploy/config.example.yaml`, `deploy/local.example.zone`, `deploy/zones/`.
|
||||
None of it has been *reviewed against the code* — the CI workflow and the
|
||||
Makefile targets (`make web`, `make race`, `make image`) need a
|
||||
`make -n`/dry-run pass, and every config key, port and CLI flag the docs
|
||||
mention has to be checked against `internal/config` and `cmd/fancy-dns`.
|
||||
- **`deploy/k8s/` has only two of the seven expected manifests**
|
||||
(`00-config.yaml`, `10-deployment.yaml`). Still missing: Service, PVC,
|
||||
ServiceAccount + Secret (the API key), Ingress (needs
|
||||
`proxy_buffering off` for the SSE log stream), namespace and kustomization.
|
||||
- End-to-end verification of the console against a running binary: the bundle
|
||||
compiles and embeds, but nobody has clicked through the pages.
|
||||
- `internal/web/dist` is gitignored except `.gitkeep`, so a fresh clone cannot
|
||||
build the binary without running `npm ci && npm run build` first. Either CI
|
||||
builds the bundle before `go build`, or the ignore rule comes off.
|
||||
|
||||
## Continuation plan, in order
|
||||
|
||||
### Step 1 — green tests (highest priority)
|
||||
### Step 1 — the bbolt round trip (highest priority)
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./...
|
||||
go test -race ./internal/app/ -count=1 2>&1 | grep -A20 "DATA RACE" | head -60
|
||||
go test -race ./internal/cache/ -run 'TestStore' -count=1 -v
|
||||
```
|
||||
|
||||
Expected races to fix, in likelihood order:
|
||||
- `config.Store` swap hook running `app.onConfigSwap` → `pool.Rebind` and
|
||||
`reloadLocalZones` while the query path reads the old `*config.Config`.
|
||||
Fix direction: make `Pool.Rebind` build a new slice and swap an
|
||||
`atomic.Pointer` rather than mutating in place under `sync.RWMutex` while
|
||||
readers hold `RLock` on a *different* field.
|
||||
- `blocklist.Manager` `m.running` map vs `RefreshAll` goroutine + API handler.
|
||||
Already mutex-guarded; confirm it is the *Stats* pointer that races.
|
||||
- `qlog.Logger.live` ring: `Log` writes `l.live[pos%len]` from many goroutines
|
||||
while `Recent` reads. Needs a seqlock or a per-slot atomic pointer.
|
||||
- Harness reading the config file path from a test goroutine.
|
||||
Read `Snapshot` and `Load` in `internal/cache/store.go` as a pair and make the
|
||||
record format explicit — a small `encodeRecord`/`decodeRecord` pair with one
|
||||
version byte, so a future change cannot silently desync them the way this one
|
||||
did. Then add a `t.TempDir()` round-trip test that survives a process restart
|
||||
and a partially written file.
|
||||
|
||||
For each race, prefer removing shared mutable state over adding locks: the
|
||||
design already uses `atomic.Pointer` holders for ACL, blocklist, localzone and
|
||||
pool; extend that pattern.
|
||||
### Step 2 — the five blocklist gaps
|
||||
|
||||
### Step 2 — unit tests for the hot packages
|
||||
Fix the implementation against the existing assertions (see item 2 above), then
|
||||
`go test -race ./internal/blocklist/ -count=1` to green. The BOM strip and the
|
||||
`!allow` marker are the two an operator is most likely to hit.
|
||||
|
||||
Priority order (highest bug-density first):
|
||||
- `blocklist`: `Set.Match` for `*.x` (subdomains only) vs `**.x` (apex too)
|
||||
vs exact; allow-over-block priority; hosts/adblock/plain/rpz parser cases;
|
||||
`DetectFormat` sniffing; duplicate handling.
|
||||
- `cache`: LRU eviction, TTL clamping, `TTLFor` on NXDOMAIN/NODATA/SERVFAIL,
|
||||
serve-stale window, key pack/unpack round-trip, bbolt snapshot→load.
|
||||
- `zonefile`: `$TTL` inheritance, omitted owner/TTL/class, parens, `@`,
|
||||
`$ORIGIN` relative vs absolute names, `ParseTTL`/`SerialNext`.
|
||||
- `acl`: first-match-wins, most-specific CIDR, PTR suffix, `DomainSet.Match`.
|
||||
- `localzone`: delegation cut, closest-wildcard (RFC 1034 §4.3.3), NODATA.
|
||||
- `resolver`: breaker open/close, parallel race, TCP fallback on truncation.
|
||||
- Add `BenchmarkLookup` for `Set.Match` and `cache.LRU.Get` to substantiate
|
||||
the "great performance" claim.
|
||||
### Step 3 — the remaining unit tests
|
||||
|
||||
### Step 3 — finish the frontend
|
||||
Same checklist as before, minus what is now covered (`blocklist`, `cache`,
|
||||
`zonefile` have suites; `cache` and `blocklist` have benchmarks). Priority:
|
||||
`resolver` (breaker open/close, parallel race, TCP fallback on truncation,
|
||||
failover, `Rebind` under `-race` — with a `dns.Server` fake on `127.0.0.1:0`),
|
||||
`acl` (first-match-wins, most-specific CIDR, PTR suffix, `DomainSet` wildcard
|
||||
forms), `localzone` (delegation cut, closest-wildcard per RFC 1034 §4.3.3,
|
||||
NODATA vs NXDOMAIN), `config` (`Duration` parsing, `Validate`, the store hooks).
|
||||
|
||||
Respawn two subagents with disjoint file ownership (this worked structurally;
|
||||
they just need more budget):
|
||||
### Step 4 — review the deploy set and write the README
|
||||
|
||||
- **Foundation agent** owns `src/index.css` (replace the purple template with
|
||||
dark-first CSS vars + one emerald accent), `src/lib/api.ts`, `src/hooks/*`,
|
||||
`src/components/ui/*`, `src/components/layout/*`. The exact prop contract I
|
||||
gave them is in the transcript and must be kept.
|
||||
- **Pages agent** owns `src/pages/*` only, coding against the same contract.
|
||||
|
||||
Then wire `src/App.tsx` + `src/main.tsx` yourself (react-router, `AppShell`,
|
||||
`ToastProvider`, theme init) and run `npm run build`. Once `web/dist` is real,
|
||||
the `//go:embed all:dist` in `internal/web/web.go` picks it up with no Go
|
||||
change; delete the placeholder `internal/web/dist/index.html`.
|
||||
|
||||
API contract for the frontend is fixed and implemented — see
|
||||
`internal/api/handlers.go` route table and `internal/app/hooks.go` payload
|
||||
shapes. `/api/logs/stream` is SSE with events `ready`, `entry`, `stats`, and
|
||||
`X-Accel-Buffering: no`.
|
||||
|
||||
### Step 4 — deploy + docs
|
||||
|
||||
- `Makefile`: `build`, `test`, `race`, `vet`, `web`, `lint`, `run`, `image`.
|
||||
- `Dockerfile`: multi-stage — node build for `web/`, then Go build with
|
||||
`-ldflags "-X ...app.Version=..."`; distroless/scratch; `EXPOSE 53/udp 53/tcp
|
||||
853/tcp 443/tcp 8080/tcp`; non-root; `VOLUME /var/lib/fancy-dns`.
|
||||
- k8s: Deployment (hostNetwork or a NodePort Service for :53), Service,
|
||||
ConfigMap for the YAML, PVC for the bbolt files, readiness probe on
|
||||
`/api/healthz`, optional Ingress for the console.
|
||||
- CI: `go vet`, `go test -race`, `gofmt -l`, `npm ci && tsc -b && npm run build`.
|
||||
- `README.md`: quickstart, config reference, zone-file semantics, API
|
||||
reference, performance notes, the security caveats that actually matter
|
||||
(`trusted_proxies` and `X-Forwarded-For`; `metrics.protect`; the
|
||||
`trust_loopback` + `auth.mode` matrix).
|
||||
The console pages exist and build; verify them in a browser against a running
|
||||
binary. Then read `Makefile`, `Dockerfile` and `.github/workflows/ci.yml`
|
||||
against the code and fix what does not match, add the missing k8s manifests
|
||||
(Service, PVC, ServiceAccount + Secret, Ingress with `proxy_buffering off` for
|
||||
`/api/logs/stream`, namespace, kustomization), and write `README.md` from the
|
||||
source: config reference, zone-file semantics, blocklist formats, API routes,
|
||||
the `trusted_proxies` / `metrics.protect` / `auth.mode` security caveats, and
|
||||
the limitations (no DNSSEC validation, no zone transfer, no dynamic update).
|
||||
CI must run `npm ci && npm run build` before `go build`, because the Go build
|
||||
embeds `internal/web/dist`.
|
||||
|
||||
## Design notes worth preserving
|
||||
|
||||
- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one
|
||||
`Unpack` and yields a private copy, so serving never mutates shared state and
|
||||
TTL ageing is free. Same trade dnsmasq makes.
|
||||
- **Cache invalidation on rule changes flushes the cache** rather than
|
||||
guessing which subtrees are affected. A newly blocked name must not keep
|
||||
serving its cached good answer, and a new allow rule can rescue a name many
|
||||
lists blocked. `invalidateBlocklistCache()` in `internal/app/hooks.go`.
|
||||
- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one `Unpack`
|
||||
and yields a private copy, so serving never mutates shared state and TTL
|
||||
ageing is free. Same trade dnsmasq makes.
|
||||
- **Everything the query path reads is behind an `atomic.Pointer` holder** —
|
||||
ACL, blocklist, localzone, and now the upstream pool. Readers load the
|
||||
pointer once and work against a value that cannot change under them. This is
|
||||
the pattern the whole race cleanup settled on: prefer a new immutable
|
||||
snapshot over a lock.
|
||||
- **Cache invalidation on rule changes flushes the cache** rather than guessing
|
||||
which subtrees are affected. A newly blocked name must not keep serving its
|
||||
cached good answer. `invalidateBlocklistCache()` in `internal/app/hooks.go`.
|
||||
- **API responses share the field names in `internal/app/hooks.go`.** If you
|
||||
rename one, grep the frontend too.
|
||||
- **API keys are compared by SHA-256 hash, constant-time**, and
|
||||
@@ -199,9 +236,19 @@ shapes. `/api/logs/stream` is SSE with events `ready`, `entry`, `stats`, and
|
||||
client could spoof its identity past the ACL and the rate limiter.
|
||||
- **UDP listener resets its read deadline every iteration.** A deadline set
|
||||
once expires permanently and turns the loop into a spin that never reads
|
||||
another packet (this was a real bug caught by `TestBlocklistToggle`).
|
||||
another packet (a real bug caught by `TestBlocklistToggle`).
|
||||
- **Only the Valkey lease holder fetches lists**, so N instances do not hit a
|
||||
mirror N times. Followers serve their compiled copy.
|
||||
- **Blocked/serve-stale behaviour:** a served-stale answer gets TTL 1, an
|
||||
expired upstream answer is never cached, and a truncated answer is never
|
||||
cached (it would be served truncated forever).
|
||||
|
||||
## Test-hygiene traps
|
||||
|
||||
- `tsc -b` replays **cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`.
|
||||
After a large edit, `rm node_modules/.tmp/*.tsbuildinfo` or you will debug
|
||||
errors that no longer exist.
|
||||
- `vite build` empties `outDir` and **deletes `internal/web/dist/.gitkeep`**.
|
||||
Restore it or the directory disappears from git.
|
||||
- A test that closes a stop channel *after* `wg.Wait()` deadlocks the whole
|
||||
package and looks like "the suite hangs". Close first, then wait.
|
||||
@@ -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: ""
|
||||
@@ -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
|
||||
@@ -0,0 +1,579 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ruleStrings renders parsed rules compactly for table comparison.
|
||||
func ruleStrings(rules []Rule) []string {
|
||||
out := make([]string, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
s := r.Name + " " + r.Action.String() + " ttl=" + strconv.Itoa(int(r.TTL))
|
||||
if r.Response != "" {
|
||||
s += " resp=" + r.Response
|
||||
}
|
||||
if r.Priority != 0 {
|
||||
s += " prio=" + strconv.Itoa(r.Priority)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameRules compares parsed rules against the expectation, treating "no rules"
|
||||
// as equal whether the parser returned a nil or an empty slice.
|
||||
func sameRules(got, want []string) bool {
|
||||
return strings.Join(got, "\n") == strings.Join(want, "\n")
|
||||
}
|
||||
|
||||
func TestParseHosts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
opt ParseOptions
|
||||
want []string
|
||||
wantFormat Format
|
||||
wantComment int
|
||||
wantSkip int
|
||||
wantBad int
|
||||
wantNotice string
|
||||
}{
|
||||
{
|
||||
name: "block entry",
|
||||
data: "0.0.0.0 ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
},
|
||||
{
|
||||
name: "several names on one line",
|
||||
data: "0.0.0.0 a.example.com b.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.a.example.com block ttl=60", "**.b.example.com block ttl=60"},
|
||||
},
|
||||
{
|
||||
name: "loopback bootstrap lines are never blocked",
|
||||
data: "127.0.0.1 localhost\n::1 ip6-localhost\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantSkip: 2,
|
||||
},
|
||||
{
|
||||
name: "comments, blank lines and inline comments",
|
||||
data: "# a comment\n; another\n\n \n0.0.0.0 ads.example.com # trailing note\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
wantComment: 2,
|
||||
},
|
||||
{
|
||||
name: "custom TTL and no subdomain expansion",
|
||||
data: "0.0.0.0 ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: false, DefaultTTL: 300},
|
||||
want: []string{"ads.example.com block ttl=300"},
|
||||
},
|
||||
{
|
||||
name: "a single field is not a hosts line",
|
||||
data: "ads.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantBad: 1,
|
||||
},
|
||||
{
|
||||
name: "a non-address first field falls back to a plain domain list",
|
||||
data: "0.0.0.0 ads.example.com\nfoo bar.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
// The fallback re-parses the whole file as a plain list, so the
|
||||
// real hosts lines survive and the odd line is malformed.
|
||||
want: []string{"**.ads.example.com block ttl=60"},
|
||||
wantFormat: FormatPlain,
|
||||
wantBad: 1,
|
||||
wantNotice: "no address in first field, falling back to a plain domain list",
|
||||
},
|
||||
{
|
||||
name: "malformed lines",
|
||||
data: "0.0.0.0\n0.0.0.0 not_a_domain..x\n0.0.0.0 -bad.example.com\n",
|
||||
opt: ParseOptions{Subdomains: true},
|
||||
wantBad: 3,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rules, stats, err := Parse(FormatHosts, []byte(tc.data), tc.opt)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, tc.want) {
|
||||
t.Errorf("rules = %v, want %v", got, tc.want)
|
||||
}
|
||||
wantFormat := tc.wantFormat
|
||||
if wantFormat == "" {
|
||||
wantFormat = FormatHosts
|
||||
}
|
||||
if stats.Format != wantFormat {
|
||||
t.Errorf("stats.Format = %q, want %q", stats.Format, wantFormat)
|
||||
}
|
||||
if stats.Comments != tc.wantComment {
|
||||
t.Errorf("Comments = %d, want %d", stats.Comments, tc.wantComment)
|
||||
}
|
||||
if stats.Skipped != tc.wantSkip {
|
||||
t.Errorf("Skipped = %d, want %d", stats.Skipped, tc.wantSkip)
|
||||
}
|
||||
if stats.Malformed != tc.wantBad {
|
||||
t.Errorf("Malformed = %d, want %d", stats.Malformed, tc.wantBad)
|
||||
}
|
||||
if stats.Rules != len(rules) {
|
||||
t.Errorf("Rules = %d, want %d", stats.Rules, len(rules))
|
||||
}
|
||||
if stats.Bytes != int64(len(tc.data)) {
|
||||
t.Errorf("Bytes = %d, want %d", stats.Bytes, len(tc.data))
|
||||
}
|
||||
if tc.wantNotice != "" {
|
||||
found := false
|
||||
for _, n := range stats.Notices {
|
||||
if n == tc.wantNotice {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Notices = %v, want %q", stats.Notices, tc.wantNotice)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdblock(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"[Adblock Plus 2.0]",
|
||||
"! Title: test list",
|
||||
"! Version: 1",
|
||||
"||ads.example.com^",
|
||||
"||track.example.com^$third-party",
|
||||
"|http://banner.example.com/img.png|",
|
||||
"|https://user:pw@secure.example.com:8443/x?y#z|",
|
||||
"@@||ok.example.com^",
|
||||
"example.com##.ad",
|
||||
"banner.example.com#@#.sponsor",
|
||||
"/ad[0-9]+/",
|
||||
"/banner[0-9]+/",
|
||||
"||",
|
||||
"bare.example.com",
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"**.ads.example.com block ttl=60",
|
||||
"**.track.example.com block ttl=60",
|
||||
"**.banner.example.com block ttl=60",
|
||||
"**.secure.example.com block ttl=60",
|
||||
"**.ok.example.com allow ttl=60 prio=10",
|
||||
"**.bare.example.com block ttl=60",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Rules != len(want) {
|
||||
t.Errorf("Rules = %d, want %d", stats.Rules, len(want))
|
||||
}
|
||||
if stats.Comments != 3 { // the header plus two "! " comments
|
||||
t.Errorf("Comments = %d, want 3", stats.Comments)
|
||||
}
|
||||
// 2 cosmetic rules, 2 regex rules, 1 empty "||" host.
|
||||
if stats.Skipped != 5 {
|
||||
t.Errorf("Skipped = %d, want 5", stats.Skipped)
|
||||
}
|
||||
foundNotice := false
|
||||
for _, n := range stats.Notices {
|
||||
if n == "skipped regular-expression rules" {
|
||||
foundNotice = true
|
||||
}
|
||||
}
|
||||
if !foundNotice {
|
||||
t.Errorf("Notices = %v, want the regex notice", stats.Notices)
|
||||
}
|
||||
if stats.Format != FormatAdblock {
|
||||
t.Errorf("Format = %q", stats.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlain(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"# a comment",
|
||||
"; another comment",
|
||||
"",
|
||||
" spaced.example.com ",
|
||||
"!ok.example.com",
|
||||
"0.0.0.0 hoststyle.example.com",
|
||||
"||ads.example.com",
|
||||
"trailing.example.com.",
|
||||
"!not a domain line !!",
|
||||
"-bad.example.com",
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatPlain, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"**.spaced.example.com block ttl=60",
|
||||
"**.ok.example.com allow ttl=60",
|
||||
"**.hoststyle.example.com block ttl=60",
|
||||
"**.ads.example.com block ttl=60",
|
||||
// A trailing dot survives parsing; Builder.Add strips it later.
|
||||
"**.trailing.example.com. block ttl=60",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Comments != 2 {
|
||||
t.Errorf("Comments = %d, want 2", stats.Comments)
|
||||
}
|
||||
if stats.Malformed != 2 {
|
||||
t.Errorf("Malformed = %d, want 2", stats.Malformed)
|
||||
}
|
||||
|
||||
// Without subdomain expansion the same list yields exact rules.
|
||||
exact, _, err := Parse(FormatPlain, []byte("spaced.example.com\n!ok.example.com\n"), ParseOptions{Subdomains: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
wantExact := []string{"spaced.example.com block ttl=60", "ok.example.com allow ttl=60"}
|
||||
if got := ruleStrings(exact); !sameRules(got, wantExact) {
|
||||
t.Errorf("exact rules = %v, want %v", got, wantExact)
|
||||
}
|
||||
|
||||
// The documented "!allow <domain>" spelling is not what the parser
|
||||
// implements: it only strips the "!" marker, so the word "allow" is left
|
||||
// in the name and the line is counted as malformed. Real behaviour,
|
||||
// pinned so a fix shows up as a failure.
|
||||
allowForm, allowStats, err := Parse(FormatPlain, []byte("!allow ok.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("\"!allow ok.example.com\": rules=%v malformed=%d", ruleStrings(allowForm), allowStats.Malformed)
|
||||
if allowStats.Rules != 0 || allowStats.Malformed != 1 {
|
||||
t.Errorf("\"!allow x\": rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
allowStats.Rules, allowStats.Malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRPZ(t *testing.T) {
|
||||
data := strings.Join([]string{
|
||||
"$TTL 3600",
|
||||
"; an RPZ policy zone",
|
||||
"example.com CNAME .",
|
||||
"*.sub.example.org CNAME rpz-drop.",
|
||||
"**.both.example.net CNAME rpz-passthru.",
|
||||
"redirect.example.gov A 127.0.0.2",
|
||||
"redirect6.example.gov AAAA ::1",
|
||||
"example.com CNAME .",
|
||||
`skip.example.com TXT "not-a-known-action"`,
|
||||
}, "\n")
|
||||
rules, stats, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
// A bare owner name becomes the STRICT-subdomain form "*." (RPZ
|
||||
// semantics: the policy covers what is under the name), unlike the
|
||||
// hosts parser which uses "**.". Zone owner names keep their
|
||||
// trailing dot; Builder.Add strips it at compile time.
|
||||
"*.example.com. nxdomain ttl=3600",
|
||||
"*.sub.example.org. drop ttl=3600",
|
||||
"**.both.example.net. passthrough ttl=3600",
|
||||
"*.redirect.example.gov. redirect ttl=3600 resp=127.0.0.2",
|
||||
"*.redirect6.example.gov. redirect ttl=3600 resp=::1",
|
||||
}
|
||||
if got := ruleStrings(rules); !sameRules(got, want) {
|
||||
t.Errorf("rules = %v,\nwant %v", got, want)
|
||||
}
|
||||
if stats.Duplicates != 1 {
|
||||
t.Errorf("Duplicates = %d, want 1 (the repeated example.com record)", stats.Duplicates)
|
||||
}
|
||||
if stats.Skipped != 1 {
|
||||
t.Errorf("Skipped = %d, want 1 (TXT with an unknown action)", stats.Skipped)
|
||||
}
|
||||
if stats.Format != FormatRPZ {
|
||||
t.Errorf("Format = %q, want rpz", stats.Format)
|
||||
}
|
||||
|
||||
// Without subdomain expansion the wildcard prefix is left off entirely.
|
||||
plain, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(plain); !sameRules(got, []string{"example.com. nxdomain ttl=60"}) {
|
||||
t.Errorf("no-subdomain rules = %v", got)
|
||||
}
|
||||
|
||||
// A CNAME to a real target is a rewrite rule and $TTL is honoured.
|
||||
cname, _, err := Parse(FormatRPZ, []byte("$TTL 60\nshop.example.com CNAME parked.example.net.\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got := ruleStrings(cname); !sameRules(got, []string{"*.shop.example.com. cname ttl=60 resp=parked.example.net."}) {
|
||||
t.Errorf("cname rules = %v", got)
|
||||
}
|
||||
|
||||
// The compiled forms behave as documented: "*." skips the apex while
|
||||
// "**." covers it.
|
||||
s := mustBuild(rules...)
|
||||
if e := s.Match("example.com"); e != nil {
|
||||
t.Errorf("RPZ \"example.com CNAME .\" matched the apex: %q, want nil (strict \"*.\")", e.Name)
|
||||
}
|
||||
if e := s.Match("www.example.com"); e == nil || e.Action != ActionNXDomain {
|
||||
t.Errorf("www.example.com: %+v, want nxdomain", e)
|
||||
}
|
||||
if e := s.Match("both.example.net"); e == nil || e.Action != ActionPassthrough {
|
||||
t.Errorf("both.example.net: %+v, want passthrough (\"**.\" covers the apex)", e)
|
||||
}
|
||||
if e := s.Match("deep.both.example.net"); e == nil || e.Action != ActionPassthrough {
|
||||
t.Errorf("deep.both.example.net: %+v, want passthrough", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want Format
|
||||
}{
|
||||
{"hosts", "0.0.0.0 ads.example.com\n127.0.0.1 localhost\n", FormatHosts},
|
||||
{"hosts after comments", "# comment\n\n 127.0.0.1 localhost\n", FormatHosts},
|
||||
{"hosts with ::1", "::1 ip6-localhost\n", FormatHosts},
|
||||
{"adblock double pipe", "||ads.example.com^\n", FormatAdblock},
|
||||
{"adblock allow", "@@||ok.example.com^\n", FormatAdblock},
|
||||
{"adblock cosmetic", "example.com##.ad\n", FormatAdblock},
|
||||
{"adblock after header", "[Adblock Plus 2.0]\n! comment\n|http://a.example.com/x|\n", FormatAdblock},
|
||||
{"rpz ttl", "$TTL 3600\nexample.com CNAME .\n", FormatRPZ},
|
||||
{"rpz origin", "$ORIGIN example.com.\nexample.com CNAME .\n", FormatRPZ},
|
||||
{"rpz record with ttl and class", "example.com 3600 IN CNAME .\n", FormatRPZ},
|
||||
{"single plain domain", "ads.example.com\n", FormatPlain},
|
||||
{"empty", "", FormatPlain},
|
||||
{"only comments", "# nothing here\n; really\n", FormatPlain},
|
||||
// A two-line plain list is sniffed as adblock: the second meaningful
|
||||
// line is taken to be an adblock rule. Both parsers turn a bare
|
||||
// hostname into a block rule, so the rules still come out right.
|
||||
{"two plain domains", "ads.example.com\ntrack.example.com\n", FormatAdblock},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DetectFormat([]byte(tc.data)); got != tc.want {
|
||||
t.Errorf("DetectFormat(%q) = %q, want %q", tc.data, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFormatSuspectedBugs(t *testing.T) {
|
||||
// Real behaviour today, pinned so a future fix shows up as a failure.
|
||||
// allDigitsOrName() does not know RR type names, so a policy record
|
||||
// without an explicit TTL/class column is sniffed as a plain domain
|
||||
// list. Parsing the same content as RPZ works, which isolates the
|
||||
// detection step.
|
||||
if got := DetectFormat([]byte("example.com CNAME .\n")); got != FormatPlain {
|
||||
t.Errorf("DetectFormat(rpz record) = %q, want plain (current behaviour)", got)
|
||||
}
|
||||
rules, _, err := Parse(FormatRPZ, []byte("example.com CNAME .\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil || len(rules) != 1 {
|
||||
t.Errorf("explicit RPZ parse of the same record: rules=%v err=%v", ruleStrings(rules), err)
|
||||
}
|
||||
|
||||
// The "0 address" form that FormatHosts documents as accepted is not an
|
||||
// IP literal for netip, so the hosts parser counts the line as malformed
|
||||
// instead of blocking the name.
|
||||
_, stats, err := Parse(FormatHosts, []byte("0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if stats.Rules != 0 || stats.Malformed != 1 {
|
||||
t.Errorf("\"0 ads.example.com\": rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
stats.Rules, stats.Malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAutoPicksFormat(t *testing.T) {
|
||||
rules, stats, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if stats.Format != FormatHosts {
|
||||
t.Errorf("Format = %q, want hosts", stats.Format)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
|
||||
t.Errorf("rules = %v", ruleStrings(rules))
|
||||
}
|
||||
// An empty format string means "sniff" too.
|
||||
if _, stats, _ := Parse("", []byte("||ads.example.com^\n"), ParseOptions{Subdomains: true}); stats.Format != FormatAdblock {
|
||||
t.Errorf("Format = %q, want adblock", stats.Format)
|
||||
}
|
||||
if _, _, err := Parse("bogus", []byte("x"), ParseOptions{}); err == nil {
|
||||
t.Errorf("Parse with an unknown format returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRobustness(t *testing.T) {
|
||||
long := "long" + strings.Repeat("x", 100000) + ".example.com"
|
||||
bigDomain := strings.Repeat("d", 250) + ".example.com" // over the 253-char limit
|
||||
hugeDomain := strings.Repeat("d", 3000) + ".example.com"
|
||||
|
||||
listSamples := map[string]string{
|
||||
"crlf": "0.0.0.0 ads.example.com\r\n0.0.0.0 b.example.com\r\n",
|
||||
"lone cr": "0.0.0.0 ads.example.com\r0.0.0.0 b.example.com\r",
|
||||
"leading/trailing space": "\t 0.0.0.0 ads.example.com \n",
|
||||
"blank and space-only": "\n\n \n\t\n",
|
||||
"very long line": "0.0.0.0 " + long + "\n",
|
||||
"over-long label": "0.0.0.0 " + bigDomain + "\n",
|
||||
"comments everywhere": "# c\n; c\n0.0.0.0 ads.example.com # tail\n\n",
|
||||
}
|
||||
for name, data := range listSamples {
|
||||
t.Run("list/"+name, func(t *testing.T) {
|
||||
for _, format := range []Format{FormatHosts, FormatPlain, FormatAdblock} {
|
||||
rules, stats, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("%s: Parse: %v", format, err)
|
||||
continue
|
||||
}
|
||||
if stats.Rules != len(rules) {
|
||||
t.Errorf("%s: Rules = %d, want %d", format, stats.Rules, len(rules))
|
||||
}
|
||||
for _, r := range rules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The adblock and plain parsers must not be confused by CRLF either.
|
||||
for _, data := range []string{
|
||||
"||ads.example.com^\r\n@@||ok.example.com^\r\n",
|
||||
"a.example.com\r\nb.example.com\r\n",
|
||||
} {
|
||||
for _, format := range []Format{FormatPlain, FormatAdblock} {
|
||||
rules, _, err := Parse(format, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("%s: Parse(%q): %v", format, data, err)
|
||||
continue
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Errorf("%s: Parse(%q) produced no rules", format, data)
|
||||
}
|
||||
for _, r := range rules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("%s: rule name kept whitespace: %q", format, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Specifically: CRLF does not leak a CR into the rule names.
|
||||
rules, _, err := Parse(FormatHosts, []byte("0.0.0.0 ads.example.com\r\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].Name != "**.ads.example.com" {
|
||||
t.Errorf("CRLF rules = %v", ruleStrings(rules))
|
||||
}
|
||||
s := mustBuild(rules...)
|
||||
if s.Match("ads.example.com") == nil || s.Match("x.ads.example.com") == nil {
|
||||
t.Errorf("CRLF-parsed rule does not match: %v", ruleStrings(rules))
|
||||
}
|
||||
|
||||
// The RPZ parser goes through the zone-file reader: CRLF, comments,
|
||||
// directives and blank lines are handled there too.
|
||||
for _, data := range []string{
|
||||
"$TTL 60\r\nexample.com CNAME .\r\n*.sub.example.org CNAME rpz-drop.\r\n",
|
||||
"; comment\r\n\r\nexample.com 60 IN CNAME .\n",
|
||||
"$ORIGIN example.com.\n$TTL 60\nwww IN A 127.0.0.1\n",
|
||||
} {
|
||||
zrules, _, err := Parse(FormatRPZ, []byte(data), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Errorf("Parse(rpz %q): %v", data, err)
|
||||
continue
|
||||
}
|
||||
for _, r := range zrules {
|
||||
if strings.ContainsAny(r.Name, "\r\n\t ") {
|
||||
t.Errorf("rule name kept whitespace: %q", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line longer than the scanner's 4 MiB cap is reported, not silently
|
||||
// truncated.
|
||||
_, _, err = Parse(FormatPlain, []byte("a.example.com\n"+hugeDomain+"\n"), ParseOptions{Subdomains: true})
|
||||
if err == nil {
|
||||
t.Errorf("an over-long line parsed without error")
|
||||
} else {
|
||||
t.Logf("over-long line error (expected): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBOMBehaviour(t *testing.T) {
|
||||
// A UTF-8 BOM on the first line is not stripped by the parsers, so the
|
||||
// first rule of the list is lost. Real behaviour, pinned.
|
||||
const bom = "\ufeff"
|
||||
_, stats, err := Parse(FormatAuto, []byte(bom+"0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("BOM sample: format=%s rules=%d malformed=%d notices=%v",
|
||||
stats.Format, stats.Rules, stats.Malformed, stats.Notices)
|
||||
if stats.Rules != 0 || stats.Malformed != 1 {
|
||||
t.Errorf("BOM sample: rules=%d malformed=%d, want 0/1 (current behaviour)",
|
||||
stats.Rules, stats.Malformed)
|
||||
}
|
||||
|
||||
// Without the BOM the same content parses.
|
||||
_, ok, err := Parse(FormatAuto, []byte("0.0.0.0 ads.example.com\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if ok.Rules != 1 {
|
||||
t.Errorf("control: rules = %d, want 1", ok.Rules)
|
||||
}
|
||||
|
||||
// A BOM on an adblock list behaves the same way: the first rule is lost.
|
||||
abRules, abStats, err := Parse(FormatAdblock, []byte(bom+"||ads.example.com^\n"), ParseOptions{Subdomains: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
t.Logf("BOM adblock: rules=%v skipped=%d", ruleStrings(abRules), abStats.Skipped)
|
||||
if abStats.Rules != 0 {
|
||||
t.Errorf("BOM adblock rules = %v, want none (current behaviour)", ruleStrings(abRules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedRulesCompileAndMatch(t *testing.T) {
|
||||
// End to end: an adblock list with a broad root rule plus a narrow
|
||||
// allow rule compiles into a set where the allow wins for the name it
|
||||
// names and the root still covers everything else.
|
||||
data := "||example.com^\n@@||metrics.example.com^\n"
|
||||
rules, _, err := Parse(FormatAdblock, []byte(data), ParseOptions{Subdomains: true, DefaultTTL: 300})
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
b := NewBuilder()
|
||||
for _, r := range rules {
|
||||
b.Add(r, "list", []string{"ads"})
|
||||
}
|
||||
s := b.Build()
|
||||
if e := s.Match("ads.example.com"); e == nil || e.Action != ActionBlock {
|
||||
t.Errorf("ads.example.com: %+v, want block", e)
|
||||
}
|
||||
if e := s.Match("example.com"); e == nil || e.Action != ActionBlock {
|
||||
t.Errorf("example.com: %+v, want block (root rule covers the apex)", e)
|
||||
}
|
||||
if e := s.Match("metrics.example.com"); e == nil || !e.IsAllow() {
|
||||
t.Errorf("metrics.example.com: %+v, want allow", e)
|
||||
}
|
||||
if e := s.Match("a.b.metrics.example.com"); e == nil || !e.IsAllow() {
|
||||
t.Errorf("a.b.metrics.example.com: %+v, want allow (the allow rule is a root rule)", e)
|
||||
}
|
||||
for _, r := range rules {
|
||||
if e := s.Lookup(r.Name); e != nil && e.TTL != 300 {
|
||||
t.Errorf("TTL = %d, want 300", e.TTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-14
@@ -214,13 +214,16 @@ type SourceMeta struct {
|
||||
|
||||
// Builder accumulates rules and produces a Set.
|
||||
type Builder struct {
|
||||
exact map[string]*Entry
|
||||
sub map[string]*Entry
|
||||
root map[string]*Entry
|
||||
meta map[string]SourceMeta
|
||||
order []string
|
||||
dup int
|
||||
counts RuleCounts
|
||||
exact map[string]*Entry
|
||||
sub map[string]*Entry
|
||||
root map[string]*Entry
|
||||
meta map[string]SourceMeta
|
||||
order []string
|
||||
// ordered mirrors order, so a source is listed once even when both a rule
|
||||
// and its metadata mention it.
|
||||
ordered map[string]bool
|
||||
dup int
|
||||
counts RuleCounts
|
||||
}
|
||||
|
||||
// NewBuilder returns an empty Builder.
|
||||
@@ -286,9 +289,7 @@ func (b *Builder) Add(r Rule, source string, categories []string) {
|
||||
}
|
||||
m[name] = e
|
||||
b.countAction(e, 1)
|
||||
if _, seen := b.meta[source]; !seen {
|
||||
b.order = append(b.order, source)
|
||||
}
|
||||
b.noteOrder(source)
|
||||
}
|
||||
|
||||
// metaFor reports whether a source already contributed to this build.
|
||||
@@ -326,12 +327,25 @@ func (b *Builder) uncount(e *Entry) {
|
||||
|
||||
// NoteSource records (or updates) bookkeeping for a contributing source.
|
||||
func (b *Builder) NoteSource(meta SourceMeta) {
|
||||
if _, seen := b.meta[meta.Name]; !seen {
|
||||
b.order = append(b.order, meta.Name)
|
||||
}
|
||||
b.noteOrder(meta.Name)
|
||||
b.meta[meta.Name] = meta
|
||||
}
|
||||
|
||||
// noteOrder appends a source to the build order exactly once.
|
||||
func (b *Builder) noteOrder(name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if b.ordered == nil {
|
||||
b.ordered = make(map[string]bool, 16)
|
||||
}
|
||||
if b.ordered[name] {
|
||||
return
|
||||
}
|
||||
b.ordered[name] = true
|
||||
b.order = append(b.order, name)
|
||||
}
|
||||
|
||||
// SourceCount is the number of contributing sources.
|
||||
func (b *Builder) SourceCount() int { return len(b.meta) }
|
||||
|
||||
@@ -731,7 +745,13 @@ func NewHolder(s *Set) *Holder {
|
||||
}
|
||||
|
||||
// Get returns the current set (nil before the first build).
|
||||
func (h *Holder) Get() *Set { return h.cur.Load() }
|
||||
// Get returns the current set, or nil when nothing has been compiled yet.
|
||||
func (h *Holder) Get() *Set {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.cur.Load()
|
||||
}
|
||||
|
||||
// Set installs a new set.
|
||||
func (h *Holder) Set(s *Set) { h.cur.Store(s) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+9
-3
@@ -18,6 +18,7 @@ package cache
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
@@ -89,7 +90,9 @@ func canonical(name string) string {
|
||||
//
|
||||
// varint len | wire name | uint16 type | uint16 class | uint8 flags
|
||||
func packKey(buf []byte, k Key) ([]byte, error) {
|
||||
name := k.Name
|
||||
// Keys are stored canonically (lowercase, no trailing dot) but the wire
|
||||
// format needs a fully qualified name, so qualify it before packing.
|
||||
name := dns.Fqdn(k.Name)
|
||||
if n := len(name); n > 255*4 {
|
||||
return nil, errKeyTooLong
|
||||
}
|
||||
@@ -160,8 +163,11 @@ type Entry struct {
|
||||
StaleUntil time.Time
|
||||
// Bytes is the accounted size, used by the LRU and the size gauges.
|
||||
Bytes int
|
||||
// Hits counts serves since it was stored.
|
||||
Hits uint32
|
||||
// Hits counts serves since it was stored. It is atomic because an Entry is
|
||||
// shared by every goroutine that hits the key (the LRU hands out the same
|
||||
// pointer on purpose, so a hit never copies the message), and the
|
||||
// counter is bumped outside the shard lock on the hot path.
|
||||
Hits atomic.Uint32
|
||||
// Neg marks a cached NXDOMAIN or NODATA answer.
|
||||
Neg bool
|
||||
// Truncated records that the upstream answer did not fit in 512 bytes,
|
||||
|
||||
Vendored
+870
@@ -0,0 +1,870 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// fakeClock is a manually advanced clock so TTL, ageing and serve-stale
|
||||
// behaviour is tested without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} }
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// answer builds a packed A response for name.
|
||||
func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg {
|
||||
t.Helper()
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
|
||||
m.Response = true
|
||||
m.Answer = []dns.RR{&dns.A{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
|
||||
A: net.ParseIP(ip).To4(),
|
||||
}}
|
||||
return m
|
||||
}
|
||||
|
||||
func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) }
|
||||
|
||||
func TestLRUGetPutAndHitKinds(t *testing.T) {
|
||||
clk := newClock()
|
||||
l := NewLRU(100, 4)
|
||||
m := answer(t, "example.com", 60, "1.2.3.4")
|
||||
wire, err := m.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("pack: %v", err)
|
||||
}
|
||||
k := key("example.com")
|
||||
now := clk.Now()
|
||||
l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)})
|
||||
|
||||
if e, kind := l.Get(k, now); e == nil || kind != Fresh {
|
||||
t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind)
|
||||
}
|
||||
// One second later the entry is still fresh; after the TTL it is stale but
|
||||
// answerable until StaleUntil, and past that it is a miss and dropped.
|
||||
clk.Advance(59 * time.Second)
|
||||
if _, kind := l.Get(k, clk.Now()); kind != Fresh {
|
||||
t.Errorf("at TTL-1s: kind = %v, want Fresh", kind)
|
||||
}
|
||||
clk.Advance(2 * time.Second)
|
||||
e, kind := l.Get(k, clk.Now())
|
||||
if e == nil || kind != Stale {
|
||||
t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind)
|
||||
}
|
||||
if got := e.Hits.Load(); got != 3 {
|
||||
t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got)
|
||||
}
|
||||
clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s)
|
||||
if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss {
|
||||
t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind)
|
||||
}
|
||||
if got := l.Stats().Entries; got != 0 {
|
||||
t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got)
|
||||
}
|
||||
st := l.Stats()
|
||||
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 1 {
|
||||
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=1", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) {
|
||||
// One shard so the budget is exactly what we set: eviction order is then
|
||||
// fully determined and the test can assert it.
|
||||
l := NewLRU(4, 1)
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
put := func(name string) {
|
||||
l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8})
|
||||
}
|
||||
put("a.example.com")
|
||||
put("b.example.com")
|
||||
put("c.example.com")
|
||||
// Touch a so it is the most recent, then overflow the 4-entry budget.
|
||||
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
|
||||
t.Fatalf("a: kind = %v, want Fresh", kind)
|
||||
}
|
||||
put("d.example.com")
|
||||
put("e.example.com") // 5 inserts, budget 4
|
||||
|
||||
if got := l.Stats().Entries; got > 4 {
|
||||
t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got)
|
||||
}
|
||||
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
|
||||
t.Errorf("a was recently used, want it kept")
|
||||
}
|
||||
if _, kind := l.Get(key("b.example.com"), now); kind != Miss {
|
||||
t.Errorf("b was the least recently used, want it evicted")
|
||||
}
|
||||
if l.Stats().Evictions == 0 {
|
||||
t.Error("evictions = 0, want > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUDeleteWhereAndFlush(t *testing.T) {
|
||||
l := NewLRU(100, 2)
|
||||
now := newClock().Now()
|
||||
names := []string{"x.example.com", "y.example.com", "z.other.net"}
|
||||
for _, n := range names {
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
dropped := l.DeleteWhere(func(k Key, _ *Entry) bool {
|
||||
return k.Name == "x.example.com" || k.Name == "y.example.com"
|
||||
})
|
||||
if dropped != 2 {
|
||||
t.Errorf("DeleteWhere = %d, want 2", dropped)
|
||||
}
|
||||
if _, kind := l.Get(key("x.example.com"), now); kind != Miss {
|
||||
t.Error("x.example.com still present after DeleteWhere")
|
||||
}
|
||||
if _, kind := l.Get(key("z.other.net"), now); kind != Fresh {
|
||||
t.Error("z.other.net must survive an unrelated DeleteWhere")
|
||||
}
|
||||
l.Flush()
|
||||
if got := l.Stats().Entries; got != 0 {
|
||||
t.Errorf("entries after Flush = %d, want 0", got)
|
||||
}
|
||||
if l.Stats().Bytes != 0 {
|
||||
t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
|
||||
l := NewLRU(400, 4) // budget is per shard: 100 entries per shard
|
||||
now := newClock().Now()
|
||||
for i := range 200 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
seen := make(map[string]int, 200)
|
||||
l.Walk(func(k Key, _ *Entry) bool {
|
||||
seen[k.Name]++
|
||||
return true
|
||||
})
|
||||
if len(seen) != 200 {
|
||||
t.Fatalf("Walk visited %d names, want 200", len(seen))
|
||||
}
|
||||
for n, c := range seen {
|
||||
if c != 1 {
|
||||
t.Fatalf("%s visited %d times, want 1", n, c)
|
||||
}
|
||||
}
|
||||
// Returning false stops the walk.
|
||||
count := 0
|
||||
l.Walk(func(Key, *Entry) bool { count++; return false })
|
||||
if count != 1 {
|
||||
t.Errorf("Walk with stop visited %d entries, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) {
|
||||
l := NewLRU(100, 1)
|
||||
now := newClock().Now()
|
||||
for i := range 50 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
l.SetCapacity(10, 1)
|
||||
if got := l.Stats().Entries; got > 10 {
|
||||
t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got)
|
||||
}
|
||||
if got := l.Stats().Shards; got != 1 {
|
||||
t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got)
|
||||
}
|
||||
// The cache must still work after a resize.
|
||||
l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh {
|
||||
t.Error("a Put after a resize must be readable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUConcurrentPutGetResize(t *testing.T) {
|
||||
// Exercises the resizing path: SetCapacity rewrites the per-shard budget
|
||||
// while other goroutines insert and read. Run with -race.
|
||||
l := NewLRU(512, 8)
|
||||
now := newClock().Now()
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
l.SetCapacity(200, 4)
|
||||
l.SetCapacity(4096, 16)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for g := range 8 {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := range 500 {
|
||||
n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com"
|
||||
k := key(n)
|
||||
l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
e, _ := l.Get(k, now)
|
||||
if e != nil {
|
||||
_ = e.Hits.Load()
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
// Readers keep walking while the resizer runs.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
l.Walk(func(Key, *Entry) bool { return true })
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Let the background loops run, then release them and wait for every
|
||||
// goroutine: closing before the wait is what keeps this from deadlocking.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
if l.Stats().Bytes < 0 {
|
||||
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPackUnpackRoundTrip(t *testing.T) {
|
||||
cases := []Key{
|
||||
{Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET},
|
||||
{Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true},
|
||||
{Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3},
|
||||
{Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7},
|
||||
}
|
||||
for _, k := range cases {
|
||||
buf, err := packKey(nil, k)
|
||||
if err != nil {
|
||||
t.Fatalf("packKey(%v): %v", k, err)
|
||||
}
|
||||
got, rest, err := unpackKey(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unpackKey(%v): %v", k, err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest))
|
||||
}
|
||||
if got != k {
|
||||
t.Errorf("round trip: got %+v, want %+v", got, k)
|
||||
}
|
||||
}
|
||||
// The wire name survives, so the canonical form comes back normalised.
|
||||
buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET})
|
||||
if err != nil {
|
||||
t.Fatalf("packKey: %v", err)
|
||||
}
|
||||
got, _, err := unpackKey(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unpackKey: %v", err)
|
||||
}
|
||||
if got.Name != "example.com" {
|
||||
t.Errorf("unpacked name = %q, want %q", got.Name, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPackRejectsBadInput(t *testing.T) {
|
||||
if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) {
|
||||
t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err)
|
||||
}
|
||||
if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil {
|
||||
t.Error("packKey(name with an empty label) error = nil, want an error")
|
||||
}
|
||||
if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) {
|
||||
t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err)
|
||||
}
|
||||
if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) {
|
||||
t.Errorf("unpackKey(empty) error = %v, want errBadKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryKeyCanonicalises(t *testing.T) {
|
||||
a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0)
|
||||
b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0)
|
||||
if a != b {
|
||||
t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b)
|
||||
}
|
||||
if a.Name != "example.com" {
|
||||
t.Errorf("canonical name = %q, want %q", a.Name, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryAgeTTL(t *testing.T) {
|
||||
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
e Entry
|
||||
now time.Time
|
||||
want uint32
|
||||
}{
|
||||
{"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70},
|
||||
{"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0},
|
||||
{"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0},
|
||||
{"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100},
|
||||
{"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1},
|
||||
}
|
||||
for i := range cases {
|
||||
// Take the address: Entry now carries an atomic counter, so the
|
||||
// struct must never be copied.
|
||||
tc := &cases[i]
|
||||
got := tc.e.AgeTTL(tc.now)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTTLFor(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
|
||||
soa := func(ttl uint32) *dns.SOA {
|
||||
return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl}
|
||||
}
|
||||
|
||||
t.Run("answers use the lowest TTL and clamp", func(t *testing.T) {
|
||||
m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
msg := answer(t, "a.example.com", 300, "1.2.3.4")
|
||||
msg.Answer = append(msg.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5},
|
||||
A: net.ParseIP("1.2.3.5").To4(),
|
||||
})
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if neg {
|
||||
t.Error("neg = true for a positive answer")
|
||||
}
|
||||
if ttl != 10 {
|
||||
t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clamp down to MaxTTL", func(t *testing.T) {
|
||||
m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil)
|
||||
ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4"))
|
||||
if ttl != 60 {
|
||||
t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
msg.Rcode = dns.RcodeNameError
|
||||
msg.Ns = []dns.RR{soa(45)}
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg {
|
||||
t.Error("neg = false for NXDOMAIN")
|
||||
}
|
||||
if ttl != 45 {
|
||||
t.Errorf("ttl = %d, want 45", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
msg.Ns = []dns.RR{soa(12)}
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg || ttl != 12 {
|
||||
t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg || ttl != 30 {
|
||||
t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated answers are never cached", func(t *testing.T) {
|
||||
msg := answer(t, "big.example.com", 600, "1.2.3.4")
|
||||
msg.Truncated = true
|
||||
if ttl, _ := m.TTLFor(msg); ttl != 0 {
|
||||
t.Errorf("ttl = %d for a truncated answer, want 0", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient rcodes are never cached", func(t *testing.T) {
|
||||
for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} {
|
||||
msg := answer(t, "a.example.com", 600, "1.2.3.4")
|
||||
msg.Rcode = rc
|
||||
if ttl, _ := m.TTLFor(msg); ttl != 0 {
|
||||
t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil message", func(t *testing.T) {
|
||||
if ttl, _ := m.TTLFor(nil); ttl != 0 {
|
||||
t.Errorf("ttl = %d, want 0", ttl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManagerPutResponseGetAndAgeing(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("www.example.com")
|
||||
m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53")
|
||||
|
||||
res := m.Get(k)
|
||||
if res.Kind != Fresh {
|
||||
t.Fatalf("kind = %v, want Fresh", res.Kind)
|
||||
}
|
||||
if res.TTL != 120 {
|
||||
t.Errorf("TTL = %d, want 120", res.TTL)
|
||||
}
|
||||
if res.Upstream != "127.0.0.53:53" {
|
||||
t.Errorf("upstream = %q, want the address it came from", res.Upstream)
|
||||
}
|
||||
if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
|
||||
t.Errorf("answer = %s, want 93.184.216.34", got)
|
||||
}
|
||||
|
||||
// The stored copy is private: mutating the returned message must not
|
||||
// change what the next reader sees.
|
||||
res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4()
|
||||
again := m.Get(k)
|
||||
if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
|
||||
t.Errorf("answer after mutating the first result = %s, want an independent copy", got)
|
||||
}
|
||||
|
||||
// TTLs age down monotonically.
|
||||
clk.Advance(30 * time.Second)
|
||||
if got := m.Get(k).TTL; got != 90 {
|
||||
t.Errorf("TTL after 30s = %d, want 90", got)
|
||||
}
|
||||
if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 {
|
||||
t.Errorf("record TTL after 30s = %d, want 90", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerServeStale(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil)
|
||||
k := key("stale.example.com")
|
||||
m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up")
|
||||
|
||||
clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window
|
||||
res := m.Get(k)
|
||||
if res.Kind != Stale {
|
||||
t.Fatalf("kind = %v, want Stale", res.Kind)
|
||||
}
|
||||
if !res.ServedStale {
|
||||
t.Error("ServedStale = false on a stale hit")
|
||||
}
|
||||
if res.TTL != 0 {
|
||||
t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL)
|
||||
}
|
||||
clk.Advance(61 * time.Second) // now past TTL+ServeStale
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v past the stale window, want Miss", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerServeStaleDisabled(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("nostale.example.com")
|
||||
m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up")
|
||||
clk.Advance(61 * time.Second)
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("nocache.example.com")
|
||||
m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up")
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDeleteNameAndSubtree(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"}
|
||||
for _, n := range names {
|
||||
m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up")
|
||||
}
|
||||
// "sub.blocked.example.com" must not be caught by the "blocked.example.com"
|
||||
// subtree walk, but "blocked.example.com" must not catch
|
||||
// "notblocked.example.com" either.
|
||||
if n := m.DeleteName("Blocked.Example.Com."); n != 1 {
|
||||
t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n)
|
||||
}
|
||||
if res := m.Get(key("blocked.example.com")); res.Kind != Miss {
|
||||
t.Error("deleted name still served")
|
||||
}
|
||||
if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 {
|
||||
t.Errorf("DeleteSubtree = %d, want 1", n)
|
||||
}
|
||||
for _, n := range []string{"keep.example.com", "notblocked.example.com"} {
|
||||
if res := m.Get(key(n)); res.Kind != Fresh {
|
||||
t.Errorf("%s was dropped by an unrelated delete", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
for range 5 {
|
||||
m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up")
|
||||
}
|
||||
for range 2 {
|
||||
m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up")
|
||||
}
|
||||
top := m.TopEntries(10)
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("TopEntries returned %d rows, want at least 2", len(top))
|
||||
}
|
||||
// Each Put counts too, so a name queried once still shows up.
|
||||
var hot, warm uint64
|
||||
for _, e := range top {
|
||||
switch e.Name {
|
||||
case "hot.example.com":
|
||||
hot = e.Hits
|
||||
case "warm.example.com":
|
||||
warm = e.Hits
|
||||
}
|
||||
}
|
||||
if hot < 5 || warm < 2 {
|
||||
t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm)
|
||||
}
|
||||
if top[0].Name != "hot.example.com" {
|
||||
t.Errorf("first row = %s, want the hottest name first", top[0].Name)
|
||||
}
|
||||
m.Flush()
|
||||
if got := len(m.TopEntries(10)); got != 0 {
|
||||
t.Errorf("TopEntries after Flush = %d rows, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStatsShape(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil)
|
||||
m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up")
|
||||
m.Get(key("a.example.com"))
|
||||
m.Get(key("miss.example.com"))
|
||||
s := m.Stats()
|
||||
if !s.Enabled {
|
||||
t.Error("Enabled = false on a live manager")
|
||||
}
|
||||
if s.Entries != 1 {
|
||||
t.Errorf("Entries = %d, want 1", s.Entries)
|
||||
}
|
||||
if s.Hits != 1 || s.Misses != 1 {
|
||||
t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses)
|
||||
}
|
||||
if s.Size != 100 || s.Shards != 4 {
|
||||
t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards)
|
||||
}
|
||||
if s.MemoryHuman == "" || s.Bytes <= 0 {
|
||||
t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman)
|
||||
}
|
||||
if s.Persist.Enabled {
|
||||
t.Error("Persist.Enabled = true without a store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KiB"},
|
||||
{1536, "1.5 KiB"},
|
||||
{1024 * 1024, "1.0 MiB"},
|
||||
{3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"},
|
||||
{-1024, "-1.0 KiB"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := HumanBytes(tc.in); got != tc.want {
|
||||
t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSnapshotAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 2)
|
||||
for i := range 25 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, err := answer(t, n, 600, "10.0.0.8").Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("pack: %v", err)
|
||||
}
|
||||
l.Put(key(n), &Entry{
|
||||
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53",
|
||||
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
|
||||
})
|
||||
}
|
||||
written, err := st.Snapshot(l, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if written != 25 {
|
||||
t.Errorf("Snapshot wrote %d entries, want 25", written)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("snapshot file missing or empty: %v", err)
|
||||
}
|
||||
|
||||
// A fresh LRU restored from the file keeps the answers and their metadata.
|
||||
restored := NewLRU(100, 2)
|
||||
loaded, skipped, err := st.Load(restored, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 25 || skipped != 0 {
|
||||
t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped)
|
||||
}
|
||||
e, kind := restored.Get(key("n7.example.com"), now)
|
||||
if e == nil || kind != Fresh {
|
||||
t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind)
|
||||
}
|
||||
if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" {
|
||||
t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream)
|
||||
}
|
||||
// The wire is intact.
|
||||
msg := new(dns.Msg)
|
||||
if err := msg.Unpack(e.Wire); err != nil {
|
||||
t.Fatalf("unpack restored wire: %v", err)
|
||||
}
|
||||
if len(msg.Answer) != 1 {
|
||||
t.Errorf("restored answer has %d records, want 1", len(msg.Answer))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoadSkipsExpiredEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 1)
|
||||
for i, ttl := range []uint32{600, 600, 5} {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, _ := answer(t, n, ttl, "10.0.0.9").Pack()
|
||||
l.Put(key(n), &Entry{
|
||||
Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
|
||||
StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire),
|
||||
})
|
||||
}
|
||||
if _, err := st.Snapshot(l, now); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
// Load 10s later: the 5s entry is dead, the two 600s entries are fresh.
|
||||
restored := NewLRU(100, 1)
|
||||
loaded, skipped, err := st.Load(restored, now.Add(10*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 2 {
|
||||
t.Errorf("loaded = %d, want 2", loaded)
|
||||
}
|
||||
if skipped != 1 {
|
||||
t.Errorf("skipped = %d, want 1", skipped)
|
||||
}
|
||||
if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss {
|
||||
t.Errorf("expired entry came back after a reload (kind = %v)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTruncateAndStats(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 1)
|
||||
wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack()
|
||||
l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
|
||||
if _, err := st.Snapshot(l, now); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
s := st.Stats()
|
||||
if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 {
|
||||
t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s)
|
||||
}
|
||||
if !s.LastSnapshot.IsZero() {
|
||||
t.Logf("LastSnapshot = %v", s.LastSnapshot)
|
||||
}
|
||||
if err := st.Truncate(); err != nil {
|
||||
t.Fatalf("Truncate: %v", err)
|
||||
}
|
||||
if s := st.Stats(); s.Entries != 0 {
|
||||
t.Errorf("entries after Truncate = %d, want 0", s.Entries)
|
||||
}
|
||||
restored := NewLRU(100, 1)
|
||||
loaded, _, err := st.Load(restored, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Load after Truncate: %v", err)
|
||||
}
|
||||
if loaded != 0 {
|
||||
t.Errorf("loaded = %d after Truncate, want 0", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerFlushDropsSnapshot checks the interaction the blocklist API
|
||||
// depends on: flushing the cache must also clear the persisted snapshot, or a
|
||||
// restart would resurrect answers an operator just invalidated.
|
||||
func TestManagerFlushDropsSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st)
|
||||
k := key("flushed.example.com")
|
||||
m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up")
|
||||
if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
m.Flush()
|
||||
restored := NewLRU(10, 1)
|
||||
loaded, _, err := st.Load(restored, clk.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 0 {
|
||||
t.Errorf("loaded = %d entries after Flush, want 0", loaded)
|
||||
}
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v after Flush, want Miss", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUStatsHitRate(t *testing.T) {
|
||||
l := NewLRU(100, 1)
|
||||
now := newClock().Now()
|
||||
l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4})
|
||||
for range 3 {
|
||||
l.Get(key("a.example.com"), now)
|
||||
}
|
||||
for range 1 {
|
||||
l.Get(key("nothere.example.com"), now)
|
||||
}
|
||||
s := l.Stats()
|
||||
if s.Hits != 3 || s.Misses != 1 {
|
||||
t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses)
|
||||
}
|
||||
if s.HitRate < 0.74 || s.HitRate > 0.76 {
|
||||
t.Errorf("HitRate = %v, want ~0.75", s.HitRate)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUGet(b *testing.B) {
|
||||
l := NewLRU(1<<16, 16)
|
||||
now := time.Now()
|
||||
names := make([]string, 4096)
|
||||
for i := range names {
|
||||
names[i] = "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack()
|
||||
l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := rand.Int()
|
||||
for pb.Next() {
|
||||
l.Get(key(names[i%len(names)]), now)
|
||||
i += 7
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkLRUPut(b *testing.B) {
|
||||
l := NewLRU(1<<16, 16)
|
||||
now := time.Now()
|
||||
b.ResetTimer()
|
||||
for i := 0; b.Loop(); i++ {
|
||||
l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkManagerGetUnpack(b *testing.B) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
names := make([]string, 4096)
|
||||
for i := range names {
|
||||
names[i] = "n" + strconv.Itoa(i) + ".example.com"
|
||||
m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up")
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := rand.Int()
|
||||
for pb.Next() {
|
||||
m.Get(key(names[i%len(names)]))
|
||||
i += 7
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+36
-10
@@ -22,8 +22,10 @@ type LRU struct {
|
||||
shards []*shard
|
||||
mask uint64
|
||||
|
||||
// perShard is the per-shard entry budget.
|
||||
perShard int
|
||||
// perShard is the per-shard entry budget. It is atomic because a config
|
||||
// reload resizes the cache from the API goroutine while queries are
|
||||
// inserting in every shard.
|
||||
perShard atomic.Int64
|
||||
|
||||
// counters are the hot-path stats, kept as atomics so Get does not need
|
||||
// the lock just to record a hit.
|
||||
@@ -69,13 +71,10 @@ func NewLRU(size, shards int) *LRU {
|
||||
l := &LRU{
|
||||
shards: make([]*shard, shards),
|
||||
mask: uint64(shards - 1),
|
||||
perShard: size / shards,
|
||||
seed: maphash.MakeSeed(),
|
||||
prefetchGate: make(chan struct{}, 64),
|
||||
}
|
||||
if l.perShard < 1 {
|
||||
l.perShard = 1
|
||||
}
|
||||
l.perShard.Store(int64(max(1, size/shards)))
|
||||
for i := range l.shards {
|
||||
l.shards[i] = &shard{
|
||||
items: make(map[Key]*list.Element, 1024),
|
||||
@@ -140,7 +139,7 @@ func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
e.Hits++
|
||||
e.Hits.Add(1)
|
||||
if kind == Fresh {
|
||||
l.hits.Add(1)
|
||||
} else {
|
||||
@@ -171,7 +170,8 @@ func (l *LRU) Put(k Key, e *Entry) {
|
||||
s.bytes += e.Bytes
|
||||
l.bytes.Add(int64(e.Bytes))
|
||||
var dropped int
|
||||
for s.ll.Len() > l.perShard {
|
||||
budget := int(l.perShard.Load())
|
||||
for s.ll.Len() > budget {
|
||||
oldest := s.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
@@ -347,7 +347,7 @@ func (l *LRU) Stats() LRUStats {
|
||||
Expired: l.expired.Load(),
|
||||
HitRate: rate,
|
||||
Shards: len(l.shards),
|
||||
Capacity: l.perShard * len(l.shards),
|
||||
Capacity: int(l.perShard.Load()) * len(l.shards),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,5 +358,31 @@ func (l *LRU) SetCapacity(size, shards int) {
|
||||
shards = len(l.shards)
|
||||
}
|
||||
shards = roundPow2(shards)
|
||||
l.perShard = max(1, size/shards)
|
||||
budget := int64(max(1, size/shards))
|
||||
l.perShard.Store(budget)
|
||||
// A shrink has to reclaim memory now, not on the next insert: after a
|
||||
// config reload lowers the cache size, the old entries would otherwise
|
||||
// sit there until they happened to be overwritten.
|
||||
var evicted int
|
||||
for _, s := range l.shards[:min(shards, len(l.shards))] {
|
||||
s.mu.Lock()
|
||||
for s.ll.Len() > int(budget) {
|
||||
oldest := s.ll.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
it, ok := oldest.Value.(*lruItem)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
s.ll.Remove(oldest)
|
||||
delete(s.items, it.key)
|
||||
s.bytes -= it.entry.Bytes
|
||||
l.bytes.Add(-int64(it.entry.Bytes))
|
||||
l.entries.Add(-1)
|
||||
evicted++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
l.evictions.Add(uint64(evicted))
|
||||
}
|
||||
+16
-6
@@ -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
|
||||
}
|
||||
|
||||
+299
-262
@@ -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 }
|
||||
Vendored
+14
-3
@@ -1,3 +1,14 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>fancy-dns</title></head>
|
||||
<body style="font:16px system-ui;padding:2rem"><h1>fancy-dns</h1>
|
||||
<p>Web console assets are not built in this binary. Run <code>make web</code> and rebuild.</p></body></html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
<script type="module" crossorigin src="./assets/index-BVuSgjZf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CZ8p4VMZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,918 @@
|
||||
package zonefile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func mustParse(t *testing.T, src string, opt Options) *Zone {
|
||||
t.Helper()
|
||||
z, err := Parse(strings.NewReader(src), opt)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: unexpected error: %v\nsource:\n%s", err, src)
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
func parseErr(t *testing.T, src string, opt Options) error {
|
||||
t.Helper()
|
||||
z, err := Parse(strings.NewReader(src), opt)
|
||||
if err == nil {
|
||||
t.Fatalf("Parse: expected an error, got %d records: %v", len(z.Records), z.Records)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// summary renders every record as "line name ttl class type rdata" so a single
|
||||
// comparison covers folding, owner/TTL/class inheritance and origin resolution.
|
||||
func summary(t *testing.T, z *Zone) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
out = append(out, renderRecord(t, rec, true))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// summaryNoTTL is summary without the TTL column, for fixtures whose records
|
||||
// carry no explicit TTL (the TTL then comes from the RR header).
|
||||
func summaryNoTTL(t *testing.T, z *Zone) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
out = append(out, renderRecord(t, rec, false))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderRecord(t *testing.T, rec Record, withTTL bool) string {
|
||||
t.Helper()
|
||||
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
||||
if len(parts) != 5 {
|
||||
t.Fatalf("record line %d does not render as 5 tab-separated fields: %q", rec.Line, rec.Text())
|
||||
}
|
||||
if !withTTL {
|
||||
return fmt.Sprintf("%d %s %s %s %s", rec.Line, parts[0], parts[2], parts[3], parts[4])
|
||||
}
|
||||
return fmt.Sprintf("%d %s %d %s %s %s", rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4])
|
||||
}
|
||||
|
||||
func checkSummary(t *testing.T, z *Zone, want []string) {
|
||||
t.Helper()
|
||||
if got := summary(t, z); !slices.Equal(got, want) {
|
||||
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func recordByName(t *testing.T, z *Zone, name string) Record {
|
||||
t.Helper()
|
||||
for _, rec := range z.Records {
|
||||
if rec.Name == name {
|
||||
return rec
|
||||
}
|
||||
}
|
||||
t.Fatalf("no record for %q in %v", name, summary(t, z))
|
||||
return Record{}
|
||||
}
|
||||
|
||||
func recordByLine(t *testing.T, z *Zone, line int) Record {
|
||||
t.Helper()
|
||||
for _, rec := range z.Records {
|
||||
if rec.Line == line {
|
||||
return rec
|
||||
}
|
||||
}
|
||||
t.Fatalf("no record starting on line %d in %v", line, summary(t, z))
|
||||
return Record{}
|
||||
}
|
||||
|
||||
func TestParseTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want uint32
|
||||
wantErr bool
|
||||
}{
|
||||
{in: "", wantErr: true},
|
||||
{in: " ", wantErr: true},
|
||||
{in: "300", want: 300},
|
||||
{in: " 60 ", want: 60},
|
||||
{in: "0", want: 0},
|
||||
{in: "300s", want: 300},
|
||||
{in: "300sec", want: 300},
|
||||
{in: "300seconds", want: 300},
|
||||
{in: "5m", want: 300},
|
||||
{in: "5min", want: 300},
|
||||
{in: "5minutes", want: 300},
|
||||
{in: "1h", want: 3600},
|
||||
{in: "1hour", want: 3600},
|
||||
{in: "1hours", want: 3600},
|
||||
{in: "1d", want: 86400},
|
||||
{in: "1days", want: 86400},
|
||||
{in: "1w", want: 604800},
|
||||
{in: "1weeks", want: 604800},
|
||||
{in: "1h30m", want: 5400},
|
||||
{in: "1w2d3h4m5s", want: 788645},
|
||||
{in: "1h30", want: 3630},
|
||||
{in: "4294967295", want: 4294967295},
|
||||
{in: "4294967296", wantErr: true},
|
||||
{in: "-1", wantErr: true},
|
||||
{in: "-5m", wantErr: true},
|
||||
{in: "abc", wantErr: true},
|
||||
{in: "m", wantErr: true},
|
||||
{in: "1x", wantErr: true},
|
||||
{in: "1.5h", wantErr: true},
|
||||
{in: "1h-", wantErr: true},
|
||||
{in: "0x10", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := ParseTTL(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseTTL(%q) = %d, want an error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseTTL(%q) error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseTTL(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
in uint32
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{59, "59"},
|
||||
{60, "1m"},
|
||||
{300, "5m"},
|
||||
{3600, "1h"},
|
||||
{86400, "1d"},
|
||||
{604800, "1w"},
|
||||
{3601, "3601"},
|
||||
{1209600, "2w"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := FormatTTL(tc.in); got != tc.want {
|
||||
t.Errorf("FormatTTL(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTTLRoundTrip(t *testing.T) {
|
||||
for _, ttl := range []uint32{0, 1, 59, 60, 300, 3600, 3601, 86400, 604800, 1209600, 4294967295} {
|
||||
s := FormatTTL(ttl)
|
||||
got, err := ParseTTL(s)
|
||||
if err != nil {
|
||||
t.Errorf("ParseTTL(FormatTTL(%d)=%q) error: %v", ttl, s, err)
|
||||
continue
|
||||
}
|
||||
if got != ttl {
|
||||
t.Errorf("round trip of %d via %q = %d", ttl, s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerialNext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current uint32
|
||||
spec string
|
||||
want uint32
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "increment", current: 1, spec: "+1", want: 2},
|
||||
{name: "increment large", current: 2024010101, spec: "+100", want: 2024010201},
|
||||
{name: "increment from zero", current: 0, spec: "+7", want: 7},
|
||||
{name: "decrement", current: 10, spec: "-1", want: 9},
|
||||
{name: "decrement below zero wraps", current: 0, spec: "-1", want: 4294967295},
|
||||
{name: "absolute", current: 5, spec: "42", want: 42},
|
||||
{name: "absolute max", current: 5, spec: "4294967295", want: 4294967295},
|
||||
{name: "surrounding space", current: 1, spec: " +1 ", want: 2},
|
||||
{name: "empty", current: 1, spec: "", wantErr: true},
|
||||
{name: "only plus", current: 1, spec: "+", wantErr: true},
|
||||
{name: "not a number", current: 1, spec: "+x", wantErr: true},
|
||||
{name: "word", current: 1, spec: "next", wantErr: true},
|
||||
{name: "bare minus", current: 1, spec: "-", wantErr: true},
|
||||
{name: "overflow", current: 1, spec: "+4294967296", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := SerialNext(tc.current, tc.spec)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("SerialNext(%d, %q) = %d, want an error", tc.current, tc.spec, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("SerialNext(%d, %q): %v", tc.current, tc.spec, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("SerialNext(%d, %q) = %d, want %d", tc.current, tc.spec, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// addSerial is plain modular (RFC 1982 §2.1) arithmetic: 0xFFFFFFFF + 1 is 0,
|
||||
// not 1. The high-half special case only stops two large operands from being
|
||||
// mistaken for a wrap.
|
||||
func TestAddSerial(t *testing.T) {
|
||||
tests := []struct{ a, b, want uint32 }{
|
||||
{1, 1, 2},
|
||||
{0, 0, 0},
|
||||
{4294967295, 1, 0},
|
||||
{4294967295, 2, 1},
|
||||
{4294967294, 3, 1},
|
||||
{0, 4294967295, 4294967295},
|
||||
{0x7FFFFFFF, 1, 0x80000000},
|
||||
{0x80000000, 0x80000000, 0},
|
||||
{0x80000000, 0x7FFFFFFF, 0xFFFFFFFF},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := addSerial(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("addSerial(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullZone = `$ORIGIN example.com.
|
||||
$TTL 1h
|
||||
@ 300 IN SOA ns1.example.com. hostmaster.example.com. (
|
||||
2024010101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
1209600 ; expire
|
||||
300 ) ; minimum
|
||||
@ IN NS ns1.example.com.
|
||||
www 3600 IN A 192.0.2.1
|
||||
www 600 IN A 192.0.2.2
|
||||
AAAA 2001:db8::1
|
||||
alias 3600 IN CNAME www.example.com.
|
||||
mail 3600 IN MX 10 mx1.example.com.
|
||||
note 3600 IN TXT "hello ; world @ example"
|
||||
*.wild 3600 IN A 192.0.2.3
|
||||
198.51.100.5. IN A 198.51.100.5
|
||||
low in a 192.0.2.7
|
||||
mixed 600 IN A 192.0.2.8
|
||||
cls in 3600 A 192.0.2.9
|
||||
at 3600 IN TXT "@"
|
||||
|
||||
; a whole-line comment
|
||||
; an indented comment
|
||||
|
||||
`
|
||||
|
||||
func TestParseFullZone(t *testing.T) {
|
||||
z := mustParse(t, fullZone, Options{File: "full.zone", Origin: "ignored.example."})
|
||||
if z.Origin != "example.com." {
|
||||
t.Errorf("Origin = %q, want %q", z.Origin, "example.com.")
|
||||
}
|
||||
if z.File != "full.zone" {
|
||||
t.Errorf("File = %q, want %q", z.File, "full.zone")
|
||||
}
|
||||
// The SOA and NS lines are folded, so every record reports the line its
|
||||
// statement started on. TTLs are left out here: only some records carry an
|
||||
// explicit one, see TestParseTTLDirective for the $TTL handling.
|
||||
if got, want := summaryNoTTL(t, z), []string{
|
||||
`3 example.com. IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`,
|
||||
`9 example.com. IN NS ns1.example.com.`,
|
||||
`10 www.example.com. IN A 192.0.2.1`,
|
||||
`11 www.example.com. IN A 192.0.2.2`,
|
||||
`12 www.example.com. IN AAAA 2001:db8::1`,
|
||||
`13 alias.example.com. IN CNAME www.example.com.`,
|
||||
`14 mail.example.com. IN MX 10 mx1.example.com.`,
|
||||
`15 note.example.com. IN TXT "hello ; world @ example"`,
|
||||
`16 *.wild.example.com. IN A 192.0.2.3`,
|
||||
`17 198.51.100.5. IN A 198.51.100.5`,
|
||||
`18 low.example.com. IN A 192.0.2.7`,
|
||||
`19 mixed.example.com. IN A 192.0.2.8`,
|
||||
`20 cls.example.com. IN A 192.0.2.9`,
|
||||
`21 at.example.com. IN TXT "@"`,
|
||||
}; !slices.Equal(got, want) {
|
||||
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
if z.SOA == nil {
|
||||
t.Fatal("SOA = nil, want the parsed start of authority")
|
||||
}
|
||||
if z.SOA.Serial != 2024010101 {
|
||||
t.Errorf("SOA.Serial = %d, want 2024010101", z.SOA.Serial)
|
||||
}
|
||||
if got := recordByLine(t, z, 11).TTL; got != 600 {
|
||||
t.Errorf("explicit TTL = %d, want 600", got)
|
||||
}
|
||||
if got := recordByLine(t, z, 10).RR.Header().Ttl; got != 3600 {
|
||||
t.Errorf("record TTL of the first www = %d, want the explicit 3600", got)
|
||||
}
|
||||
if got := recordByLine(t, z, 20).TTL; got != 3600 {
|
||||
t.Errorf("TTL before the class = %d, want 3600", got)
|
||||
}
|
||||
if z.MinTTL != 300 {
|
||||
t.Errorf("MinTTL = %d, want 300 (the smallest explicit TTL)", z.MinTTL)
|
||||
}
|
||||
for _, rec := range z.Records {
|
||||
if rec.RR.Header().Class != dns.ClassINET {
|
||||
t.Errorf("record %q has class %s, want IN", rec.Text(), dns.ClassToString[rec.RR.Header().Class])
|
||||
}
|
||||
if rec.Name != strings.ToLower(rec.Name) {
|
||||
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
|
||||
}
|
||||
if !strings.HasSuffix(rec.Name, ".") {
|
||||
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
|
||||
}
|
||||
if rec.File != "full.zone" {
|
||||
t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTTLDirective(t *testing.T) {
|
||||
z := mustParse(t, `$ORIGIN example.com.
|
||||
$TTL 1h
|
||||
one 3600 IN A 192.0.2.1
|
||||
two 300 IN A 192.0.2.2
|
||||
three 300 IN A 192.0.2.3
|
||||
$TTL 2h
|
||||
four 7200 IN A 192.0.2.4
|
||||
`, Options{})
|
||||
checkSummary(t, z, []string{
|
||||
`3 one.example.com. 3600 IN A 192.0.2.1`,
|
||||
`4 two.example.com. 300 IN A 192.0.2.2`,
|
||||
`5 three.example.com. 300 IN A 192.0.2.3`,
|
||||
`7 four.example.com. 7200 IN A 192.0.2.4`,
|
||||
})
|
||||
if z.DefaultTTL != 7200 {
|
||||
t.Errorf("DefaultTTL = %d, want 7200 after \"$TTL 2h\"", z.DefaultTTL)
|
||||
}
|
||||
if z.MinTTL != 300 {
|
||||
t.Errorf("MinTTL = %d, want 300", z.MinTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultTTLSources(t *testing.T) {
|
||||
t.Run("option default ttl", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com.", DefaultTTL: 42})
|
||||
if z.DefaultTTL != 42 {
|
||||
t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL)
|
||||
}
|
||||
if len(z.Records) != 1 {
|
||||
t.Fatalf("got %d records, want 1", len(z.Records))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no ttl source at all", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if z.DefaultTTL != 0 {
|
||||
t.Errorf("DefaultTTL = %d, want 0", z.DefaultTTL)
|
||||
}
|
||||
// The RR header already carries the library default, so MinTTL is
|
||||
// that value and the parser's "no TTL at all" fallback never runs.
|
||||
if z.MinTTL != z.Records[0].RR.Header().Ttl {
|
||||
t.Errorf("MinTTL = %d, want the record TTL %d", z.MinTTL, z.Records[0].RR.Header().Ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soa ttl becomes the default", func(t *testing.T) {
|
||||
z := mustParse(t, "@ 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5\nhost IN A 192.0.2.1\n",
|
||||
Options{Origin: "example.com."})
|
||||
if z.DefaultTTL != 300 {
|
||||
t.Errorf("DefaultTTL = %d, want 300 from the SOA", z.DefaultTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soa minimum is the last resort", func(t *testing.T) {
|
||||
z := mustParse(t, "@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 7\nhost IN A 192.0.2.1\n",
|
||||
Options{Origin: "example.com."})
|
||||
// The SOA carries no explicit TTL, so its header TTL wins over Minttl.
|
||||
if z.SOA == nil || z.SOA.Minttl != 7 {
|
||||
t.Fatalf("SOA = %v, want Minttl 7", z.SOA)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a file without ttl, soa or option keeps the header ttl", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if got := z.Records[0].RR.Header().Ttl; got == 0 {
|
||||
t.Errorf("record header TTL = 0, want the parser default")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOwnerForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "at sign is the origin",
|
||||
src: "@ IN A 192.0.2.1\n@ IN A 192.0.2.2\n",
|
||||
want: []string{"origin.example. 192.0.2.1", "origin.example. 192.0.2.2"},
|
||||
},
|
||||
{
|
||||
name: "at sign follows $ORIGIN",
|
||||
src: "$ORIGIN sub.example.com.\n@ IN A 192.0.2.1\n",
|
||||
want: []string{"sub.example.com. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
// An omitted owner is recognised by the line starting with a type
|
||||
// token; a leading class or TTL is taken as an owner name instead.
|
||||
name: "omitted owner repeats the previous one",
|
||||
src: "one IN A 192.0.2.1\n A 192.0.2.2\n AAAA 2001:db8::1\n A 192.0.2.3\n",
|
||||
want: []string{
|
||||
"one.origin.example. 192.0.2.1",
|
||||
"one.origin.example. 192.0.2.2",
|
||||
"one.origin.example. 2001:db8::1",
|
||||
"one.origin.example. 192.0.2.3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "omitted owner after a parenthesised record",
|
||||
src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n A 192.0.2.1\n",
|
||||
want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "omitted owner is the origin after a directive",
|
||||
src: "$ORIGIN example.com.\nA 192.0.2.1\n",
|
||||
want: []string{"example.com. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "wildcard owner keeps the star",
|
||||
src: "*.sub IN A 192.0.2.1\n",
|
||||
want: []string{"*.sub.origin.example. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "at sign in a value is not expanded",
|
||||
src: "note IN TXT \"user @ example.com\"\n",
|
||||
want: []string{"note.origin.example. \"user @ example.com\""},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
z := mustParse(t, tc.src, Options{Origin: "origin.example."})
|
||||
got := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
||||
got = append(got, parts[0]+" "+parts[4])
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
// Record.Name is the resolved, lowercased, fully qualified owner:
|
||||
// "@" always resolves to the origin, never to a literal "@" label.
|
||||
for _, rec := range z.Records {
|
||||
if strings.ContainsAny(rec.Name, "@") {
|
||||
t.Errorf("Record.Name = %q, want an expanded owner", rec.Name)
|
||||
}
|
||||
if !strings.HasSuffix(rec.Name, ".") {
|
||||
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
|
||||
}
|
||||
if rec.Name != strings.ToLower(rec.Name) {
|
||||
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOwnerNameCaseIsPreservedInTheRecord(t *testing.T) {
|
||||
// The RR keeps the case the file used; only Record.Name is normalised.
|
||||
z := mustParse(t, "WWW IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if got := z.Records[0].Name; got != "www.example.com." {
|
||||
t.Errorf("Record.Name = %q, want www.example.com.", got)
|
||||
}
|
||||
if !strings.HasPrefix(z.Records[0].Text(), "WWW.example.com.") {
|
||||
t.Errorf("Text() = %q, want the owner as written", z.Records[0].Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParenthesised(t *testing.T) {
|
||||
t.Run("multi line soa", func(t *testing.T) {
|
||||
z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. (
|
||||
2024010101
|
||||
7200
|
||||
3600
|
||||
1209600
|
||||
300 )
|
||||
`, Options{Origin: "example.com."})
|
||||
if z.SOA == nil {
|
||||
t.Fatal("SOA = nil")
|
||||
}
|
||||
if z.SOA.Serial != 2024010101 || z.SOA.Refresh != 7200 || z.SOA.Minttl != 300 {
|
||||
t.Errorf("SOA = %v, want serial 2024010101, refresh 7200, minimum 300", z.SOA)
|
||||
}
|
||||
if z.Records[0].Line != 1 {
|
||||
t.Errorf("Line = %d, want 1 (the start of the folded record)", z.Records[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comment inside parentheses", func(t *testing.T) {
|
||||
z := mustParse(t, "host TXT ( \"one\" ; first\n \"two\" ; second\n )\n", Options{Origin: "example.com."})
|
||||
if len(z.Records) != 1 {
|
||||
t.Fatalf("got %d records, want 1", len(z.Records))
|
||||
}
|
||||
if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) {
|
||||
t.Errorf("record = %q, want a two-string TXT", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parentheses on a single line", func(t *testing.T) {
|
||||
z := mustParse(t, "host MX ( 10 mx1.example.com. )\n", Options{Origin: "example.com."})
|
||||
if len(z.Records) != 1 {
|
||||
t.Fatalf("got %d records, want 1", len(z.Records))
|
||||
}
|
||||
if got := summaryNoTTL(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com.") {
|
||||
t.Errorf("record = %q, want one MX rdata field", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parentheses are stripped from the assembled line", func(t *testing.T) {
|
||||
lineno := 0
|
||||
lines := bufio.NewScanner(strings.NewReader("host MX 10 mx1.example.com.\n"))
|
||||
got, err := fold(lines, "host MX ( 10 mx1.example.com. )", &lineno, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("fold: %v", err)
|
||||
}
|
||||
if strings.ContainsAny(got, "()") {
|
||||
t.Errorf("fold = %q, want no parentheses", got)
|
||||
}
|
||||
// The parens and the whitespace they wrapped are gone; the token
|
||||
// stream is what the record parser cares about, not the exact spacing.
|
||||
if want := []string{"host", "MX", "10", "mx1.example.com."}; !slices.Equal(strings.Fields(got), want) {
|
||||
t.Errorf("fold = %q, tokens = %v, want %v", got, strings.Fields(got), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOriginDirective(t *testing.T) {
|
||||
t.Run("relative names follow $ORIGIN", func(t *testing.T) {
|
||||
z := mustParse(t, "$ORIGIN sub.example.com.\nwww IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if z.Origin != "sub.example.com." {
|
||||
t.Errorf("Origin = %q, want sub.example.com.", z.Origin)
|
||||
}
|
||||
if z.Records[0].Name != "www.sub.example.com." {
|
||||
t.Errorf("name = %q, want www.sub.example.com.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absolute names are left alone", func(t *testing.T) {
|
||||
z := mustParse(t, "$ORIGIN sub.example.com.\nwww.other.example. IN A 192.0.2.1\n", Options{})
|
||||
if z.Records[0].Name != "www.other.example." {
|
||||
t.Errorf("name = %q, want www.other.example.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no origin at all is the root", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{})
|
||||
if z.Origin != "." {
|
||||
t.Errorf("Origin = %q, want .", z.Origin)
|
||||
}
|
||||
if z.Records[0].Name != "host." {
|
||||
t.Errorf("name = %q, want host.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relative names at the root get no suffix", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "."})
|
||||
if z.Records[0].Name != "host." {
|
||||
t.Errorf("name = %q, want host.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("$ORIGIN without a name is rejected", func(t *testing.T) {
|
||||
// BIND resets to the zone origin when $ORIGIN carries no argument;
|
||||
// this parser treats it as an error instead.
|
||||
err := parseErr(t, "$ORIGIN example.com.\n$ORIGIN\n", Options{})
|
||||
if !strings.Contains(err.Error(), "$ORIGIN needs a name") {
|
||||
t.Errorf("error = %q, want it to mention \"$ORIGIN needs a name\"", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("origin option applies when the file has no $ORIGIN", func(t *testing.T) {
|
||||
z := mustParse(t, "www IN A 192.0.2.1\n", Options{Origin: "example.com"})
|
||||
if z.Origin != "example.com." || z.Records[0].Name != "www.example.com." {
|
||||
t.Errorf("origin = %q, name = %q", z.Origin, z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseClassHandling(t *testing.T) {
|
||||
z := mustParse(t, `one IN A 192.0.2.1
|
||||
two in A 192.0.2.2
|
||||
three A 192.0.2.3
|
||||
four 300 in A 192.0.2.4
|
||||
five ch A 192.0.2.5
|
||||
six hs A 192.0.2.6
|
||||
seven 300 A 192.0.2.7
|
||||
`, Options{Origin: "example.com."})
|
||||
if len(z.Records) != 7 {
|
||||
t.Fatalf("got %d records, want 7", len(z.Records))
|
||||
}
|
||||
// An omitted class defaults to IN; "in" and "IN" are the same token.
|
||||
want := []uint16{
|
||||
dns.ClassINET, dns.ClassINET, dns.ClassINET, dns.ClassINET,
|
||||
dns.ClassCHAOS, dns.ClassHESIOD, dns.ClassINET,
|
||||
}
|
||||
for i, rec := range z.Records {
|
||||
if rec.RR.Header().Class != want[i] {
|
||||
t.Errorf("%q has class %s, want %s", rec.Text(),
|
||||
dns.ClassToString[rec.RR.Header().Class], dns.ClassToString[want[i]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentsAndBlankLines(t *testing.T) {
|
||||
z := mustParse(t, `; leading comment
|
||||
$TTL 1h
|
||||
|
||||
; indented comment
|
||||
|
||||
one 300 IN A 192.0.2.1 ; trailing comment
|
||||
|
||||
|
||||
two 300 IN A 192.0.2.2 ;comment without space
|
||||
three 300 IN TXT "semi ; colon" ; real comment
|
||||
`, Options{Origin: "example.com."})
|
||||
if got := summary(t, z); !slices.Equal(got, []string{
|
||||
`6 one.example.com. 300 IN A 192.0.2.1`,
|
||||
`9 two.example.com. 300 IN A 192.0.2.2`,
|
||||
`10 three.example.com. 300 IN TXT "semi ; colon"`,
|
||||
}) {
|
||||
t.Errorf("records = %q, want three records on lines 6, 9 and 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
opt Options
|
||||
wantSub string
|
||||
}{
|
||||
{name: "no type", src: "host 3600\n", wantSub: "has no type"},
|
||||
{name: "bad token before type", src: "host BOGUS 1.2.3.4\n", wantSub: `unexpected token "BOGUS"`},
|
||||
{name: "bad rdata", src: "host 3600 IN A not-an-ip\n"},
|
||||
{name: "unterminated parenthesis", src: "host 3600 IN A ( 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
||||
{name: "unterminated parenthesis with a blank line", src: "host 3600 IN A (\n\n 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
||||
{name: "unterminated quote", src: "host 3600 IN TXT \"unterminated\n"},
|
||||
{name: "no owner and no previous owner", src: "A 192.0.2.1\n", wantSub: "no owner name"},
|
||||
{name: "bad ttl directive", src: "$TTL forever\n", wantSub: "bad TTL"},
|
||||
{name: "ttl directive without a value", src: "$TTL\n", wantSub: "$TTL needs a value"},
|
||||
{name: "origin directive without a name", src: "$ORIGIN\n", wantSub: "$ORIGIN needs a name"},
|
||||
{name: "include disabled", src: "$INCLUDE other.zone\n", wantSub: "disabled"},
|
||||
{name: "include unsupported", src: "$INCLUDE other.zone\n", opt: Options{MaxIncludeDepth: 3}, wantSub: "not supported"},
|
||||
{name: "include without a name", src: "$INCLUDE\n", wantSub: "$INCLUDE needs a file name"},
|
||||
{name: "error carries file and line", src: "host 3600 IN A 192.0.2.1\nother 3600 IN A nope\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
||||
{name: "directive error carries the line", src: "$TTL 1h\n$TTL\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
||||
{name: "read error is reported", src: "host 3600 IN A 192.0.2.1\n", opt: Options{File: "bad.zone"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.name == "read error is reported" {
|
||||
// A reader that fails after the first line exercises the
|
||||
// lines.Err() path in Parse.
|
||||
if _, err := Parse(&failingReader{}, Options{File: "bad.zone"}); err == nil {
|
||||
t.Fatal("Parse with a failing reader: want an error")
|
||||
}
|
||||
return
|
||||
}
|
||||
err := parseErr(t, tc.src, tc.opt)
|
||||
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error = %q, want it to contain %q", err, tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// failingReader fails on the first Read so Parse must surface the error.
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, errRead }
|
||||
|
||||
var errRead = fmt.Errorf("synthetic read failure")
|
||||
|
||||
func TestParseEmptyFile(t *testing.T) {
|
||||
for _, src := range []string{"", "\n\n", " \n\t\n", "; only a comment\n"} {
|
||||
z := mustParse(t, src, Options{Origin: "example.com."})
|
||||
if len(z.Records) != 0 {
|
||||
t.Errorf("Parse(%q) returned %d records, want 0", src, len(z.Records))
|
||||
}
|
||||
if z.SOA != nil {
|
||||
t.Errorf("Parse(%q) returned an SOA, want nil", src)
|
||||
}
|
||||
if z.Origin != "example.com." {
|
||||
t.Errorf("Parse(%q) origin = %q, want example.com.", src, z.Origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileAndParseFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a.zone")
|
||||
b := filepath.Join(dir, "b.zone")
|
||||
if err := os.WriteFile(a, []byte("$TTL 1h\nwww IN A 192.0.2.1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(b, []byte("$TTL 2h\nmail IN A 192.0.2.2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
z, err := ParseFile(a, "example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if z.File != a || z.Origin != "example.com." || len(z.Records) != 1 {
|
||||
t.Fatalf("ParseFile = %+v", z)
|
||||
}
|
||||
if z.Records[0].Name != "www.example.com." {
|
||||
t.Errorf("name = %q, want www.example.com.", z.Records[0].Name)
|
||||
}
|
||||
if z.Records[0].File != a {
|
||||
t.Errorf("record File = %q, want %q", z.Records[0].File, a)
|
||||
}
|
||||
if _, err := ParseFile(filepath.Join(dir, "missing.zone"), "example.com."); err == nil {
|
||||
t.Error("ParseFile on a missing file: want an error")
|
||||
}
|
||||
|
||||
merged, err := ParseFiles([]string{a, b}, OriginFromPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFiles: %v", err)
|
||||
}
|
||||
if len(merged.Records) != 2 {
|
||||
t.Fatalf("ParseFiles returned %d records, want 2", len(merged.Records))
|
||||
}
|
||||
if merged.DefaultTTL != 7200 {
|
||||
t.Errorf("merged DefaultTTL = %d, want 7200 (the last file wins)", merged.DefaultTTL)
|
||||
}
|
||||
if _, err := ParseFiles([]string{a, filepath.Join(dir, "missing.zone")}, OriginFromPath); err == nil {
|
||||
t.Error("ParseFiles with a missing file: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginFromPath(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"ads.zone", "ads."},
|
||||
{"rpz/ads.zone", "ads."},
|
||||
{"plain", "plain."},
|
||||
{".hidden", ".hidden."},
|
||||
{"/var/lib/fancy-dns/ads.zone", "ads."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := OriginFromPath(tc.in); got != tc.want {
|
||||
t.Errorf("OriginFromPath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDot(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{".", "."},
|
||||
{"example.com", "example.com."},
|
||||
{"example.com.", "example.com."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := ensureDot(tc.in); got != tc.want {
|
||||
t.Errorf("ensureDot(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelative(t *testing.T) {
|
||||
tests := []struct{ name, origin, want string }{
|
||||
{"@", "example.com.", "example.com."},
|
||||
{"www", "example.com.", "www.example.com."},
|
||||
{"www.other.", "example.com.", "www.other."},
|
||||
{"www", ".", "www."},
|
||||
{"*", "example.com.", "*.example.com."},
|
||||
{"www", "", "www."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := relative(tc.name, tc.origin); got != tc.want {
|
||||
t.Errorf("relative(%q, %q) = %q, want %q", tc.name, tc.origin, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripComment(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"a ; comment", "a "},
|
||||
{"a;comment", "a"},
|
||||
{`a "has ; semicolon" b`, `a "has ; semicolon" b`},
|
||||
{`a \; escaped`, `a \; escaped`},
|
||||
{"no comment here", "no comment here"},
|
||||
{`"unterminated ; quote`, `"unterminated ; quote`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := stripComment(tc.in); got != tc.want {
|
||||
t.Errorf("stripComment(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"a b c", []string{"a", "b", "c"}},
|
||||
{`a "b c" d`, []string{"a", `"b c"`, "d"}},
|
||||
{" ", nil},
|
||||
{`a\ b c`, []string{`a\ b`, "c"}},
|
||||
{"a\tb", []string{"a", "b"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := splitFields(tc.in); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeparen(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"a ( b", "a b"},
|
||||
{"a ) b", "a b"},
|
||||
{"a b", "a b"},
|
||||
{"()", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := deparen(tc.in); got != tc.want {
|
||||
t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenClassification(t *testing.T) {
|
||||
for _, s := range []string{"0", "300", "1h30m", "300s"} {
|
||||
if !isTTLToken(s) {
|
||||
t.Errorf("isTTLToken(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} {
|
||||
if isTTLToken(s) {
|
||||
t.Errorf("isTTLToken(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} {
|
||||
if !isClassToken(s) {
|
||||
t.Errorf("isClassToken(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "300", "ANY", "cs", "in4"} {
|
||||
if isClassToken(s) {
|
||||
t.Errorf("isClassToken(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} {
|
||||
if !isRRTYPE(s) {
|
||||
t.Errorf("isRRTYPE(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} {
|
||||
if isRRTYPE(s) {
|
||||
t.Errorf("isRRTYPE(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSOA(t *testing.T) {
|
||||
if cloneSOA(nil) != nil {
|
||||
t.Error("cloneSOA(nil) != nil")
|
||||
}
|
||||
in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60}
|
||||
out := cloneSOA(in)
|
||||
if out == in {
|
||||
t.Fatal("cloneSOA returned the same pointer")
|
||||
}
|
||||
if out.Serial != 7 || out.Minttl != 60 {
|
||||
t.Errorf("cloneSOA copied %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereAndDirOf(t *testing.T) {
|
||||
if got := where(""); got != "zone" {
|
||||
t.Errorf("where(\"\") = %q, want zone", got)
|
||||
}
|
||||
if got := where("a.zone"); got != "a.zone" {
|
||||
t.Errorf("where(a.zone) = %q", got)
|
||||
}
|
||||
if got := dirOf("a.zone"); got != "." {
|
||||
t.Errorf("dirOf(a.zone) = %q, want .", got)
|
||||
}
|
||||
if got := dirOf("/x/y/a.zone"); got != "/x/y" {
|
||||
t.Errorf("dirOf(/x/y/a.zone) = %q", got)
|
||||
}
|
||||
}
|
||||
+78
-116
@@ -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 `<html>` 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 `<html>` before anything paints. */
|
||||
function useThemeInit(): void {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.documentElement.dataset.theme !== undefined) return
|
||||
let stored: string | null = null
|
||||
try {
|
||||
stored = localStorage.getItem(THEME_KEY)
|
||||
} catch {
|
||||
// Storage disabled: fall back to the system preference.
|
||||
stored = null
|
||||
}
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
document.documentElement.dataset.theme = stored
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** Everything that lives inside the shell. */
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/blocklists" element={<Blocklists />} />
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
<Route path="/upstreams" element={<Upstreams />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/cache" element={<Cache />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default function App() {
|
||||
useThemeInit()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
{/* Bare: no shell, no header, no nav. */}
|
||||
<Route path="/auth/login" element={<Login />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppShell>
|
||||
<AppRoutes />
|
||||
</AppShell>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,9 @@ export function useApi<T>(fn: () => Promise<T>, 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<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, nonce])
|
||||
|
||||
+6
-6
@@ -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;
|
||||
}
|
||||
+11
-2
@@ -417,6 +417,8 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
|
||||
let backoff = RECONNECT_MIN_MS
|
||||
let timer: ReturnType<typeof setTimeout> | 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()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
|
||||
|
||||
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api'
|
||||
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../../hooks/useDebounced'
|
||||
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format'
|
||||
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
|
||||
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../hooks/useDebounced'
|
||||
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -29,21 +29,21 @@ import {
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import type { ToastOptions } from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
/**
|
||||
* One line for every mutation on this page: the message plus the tone it
|
||||
* earned. `fail` is the danger tone, `ok` the success one.
|
||||
*/
|
||||
function notify(
|
||||
toast: (options: ToastOptions) => void,
|
||||
kind: 'ok' | 'fail',
|
||||
message: string,
|
||||
): void {
|
||||
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
|
||||
}
|
||||
|
||||
const EMPTY_STATS: BlocklistStats = {
|
||||
@@ -79,7 +79,7 @@ function truncate(value: string, max = 42): string {
|
||||
}
|
||||
|
||||
export default function Blocklists() {
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
|
||||
useAutoRefresh(refetch, 15000)
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function Blocklists() {
|
||||
),
|
||||
refresh: (
|
||||
<IconButton
|
||||
icon={<RefreshCw size={14} />}
|
||||
icon={RefreshCw}
|
||||
label={`refresh ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
@@ -221,7 +221,7 @@ export default function Blocklists() {
|
||||
description="Compiled rule sources, their freshness and their health."
|
||||
actions={
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
icon={RefreshCw}
|
||||
onClick={() => void onRefresh()}
|
||||
loading={refresh.pending}
|
||||
>
|
||||
@@ -231,24 +231,24 @@ export default function Blocklists() {
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} />
|
||||
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={Layers} />
|
||||
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
|
||||
<StatCard
|
||||
label="Allow rules"
|
||||
value={formatNumber(stats.allow)}
|
||||
icon={<ShieldCheck size={16} />}
|
||||
icon={ShieldCheck}
|
||||
tone={stats.allow > 0 ? 'default' : 'warn'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Duplicates"
|
||||
value={formatNumber(stats.duplicates)}
|
||||
icon={<Split size={16} />}
|
||||
icon={Split}
|
||||
tone={stats.duplicates > 0 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Last compile"
|
||||
value={compiledAgo}
|
||||
icon={<Clock size={16} />}
|
||||
icon={Clock}
|
||||
hint={stats.built_at ? undefined : 'no build recorded'}
|
||||
/>
|
||||
</div>
|
||||
@@ -265,7 +265,7 @@ export default function Blocklists() {
|
||||
placeholder="ads.example.com"
|
||||
value={probe}
|
||||
onChange={(e) => setProbe(e.target.value)}
|
||||
icon={<ShieldCheck size={14} />}
|
||||
icon={ShieldCheck}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Cache — what the resolver keeps in memory between queries.
|
||||
*
|
||||
* Everything here is a read of `/api/cache` plus the top-50 ranking, refreshed
|
||||
* on a slow 15s tick. The only mutation is the flush, which is destructive
|
||||
* enough to sit behind a confirmation: it costs a cold cache, not a restart.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip as ChartTooltip,
|
||||
} from 'recharts'
|
||||
import { Database, FileClock, Gauge, HardDrive, Layers, RefreshCw, Timer } from 'lucide-react'
|
||||
|
||||
import { flushCache, getCache, getCacheTop } from '../lib/api'
|
||||
import type { CachePersisted, CacheTopEntry } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { formatBytes, formatMs, formatNumber, formatRate, formatRelative } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
StatCard,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
const REFRESH_MS = 15000
|
||||
const TOP_LIMIT = 50
|
||||
|
||||
/** Fixed hex so the donut reads on both themes, like the dashboard charts. */
|
||||
const HITS = '#34d399'
|
||||
const MISSES = '#f87171'
|
||||
|
||||
/** `persisted` gained fields over time; read defensively. */
|
||||
type PersistedView = Partial<CachePersisted> & {
|
||||
errors?: number
|
||||
error?: string
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
type PieSlice = { name: string; value: number; fill: string }
|
||||
|
||||
type TipProps = {
|
||||
active?: boolean
|
||||
payload?: { name?: string; value?: number | string; payload?: PieSlice }[]
|
||||
}
|
||||
|
||||
function ChartTip({ active, payload }: TipProps) {
|
||||
if (!active || payload === undefined || payload.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
|
||||
<ul className="space-y-0.5">
|
||||
{payload.map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-2 text-slate-100">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: item.payload?.fill }}
|
||||
/>
|
||||
<span className="text-slate-400">{item.name}</span>
|
||||
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Cache() {
|
||||
const { toast } = useToast()
|
||||
const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void =>
|
||||
toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' })
|
||||
|
||||
const { data, error, loading, refetch } = useApi(() => getCache(), [])
|
||||
const {
|
||||
data: top,
|
||||
error: topError,
|
||||
loading: topLoading,
|
||||
refetch: refetchTop,
|
||||
} = useApi(() => getCacheTop(TOP_LIMIT), [])
|
||||
|
||||
useAutoRefresh(() => {
|
||||
refetch()
|
||||
refetchTop()
|
||||
}, REFRESH_MS)
|
||||
|
||||
const [confirmFlush, setConfirmFlush] = useState(false)
|
||||
const flush = useAsyncAction(async () => flushCache())
|
||||
|
||||
const hits = data?.hits ?? 0
|
||||
const misses = data?.misses ?? 0
|
||||
const lookups = hits + misses
|
||||
const hitRate = data?.hit_rate ?? 0
|
||||
|
||||
const pie: PieSlice[] = useMemo(
|
||||
() => [
|
||||
{ name: 'hits', value: hits, fill: HITS },
|
||||
{ name: 'misses', value: misses, fill: MISSES },
|
||||
],
|
||||
[hits, misses],
|
||||
)
|
||||
|
||||
const persisted: PersistedView = (data?.persisted ?? {}) as PersistedView
|
||||
const persistErrors = persisted.errors ?? 0
|
||||
const persistMessage = persisted.last_error ?? persisted.error ?? ''
|
||||
|
||||
const entries: CacheTopEntry[] = useMemo(() => top?.items ?? [], [top])
|
||||
|
||||
const onFlush = async (): Promise<void> => {
|
||||
const res = await flush.run()
|
||||
if (res === null) {
|
||||
notify('Could not flush the cache', flush.error ?? 'the server rejected the request')
|
||||
return
|
||||
}
|
||||
setConfirmFlush(false)
|
||||
notify(
|
||||
'Cache flushed',
|
||||
`${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`,
|
||||
'ok',
|
||||
)
|
||||
refetch()
|
||||
refetchTop()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const topColumns = [
|
||||
{ key: 'name', header: 'Name', width: '48%' },
|
||||
{ key: 'qtype', header: 'Type', width: '10%' },
|
||||
{ key: 'count', header: 'Lookups', width: '14%', align: 'right' as const },
|
||||
{ key: 'hits', header: 'Served from cache', width: '14%', align: 'right' as const },
|
||||
{ key: 'last_seen', header: 'Last seen', width: '14%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const topRows: Row[] = entries.map((entry) => ({
|
||||
__key: `${entry.name}-${entry.qtype}`,
|
||||
name: (
|
||||
<span className="block max-w-[420px] truncate font-mono text-xs" title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
),
|
||||
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
|
||||
count: <span className="font-mono text-xs">{formatNumber(entry.count)}</span>,
|
||||
hits: <span className="font-mono text-xs">{formatNumber(entry.hits)}</span>,
|
||||
last_seen: <span className="text-xs text-fg-muted">{formatRelative(entry.last_seen)}</span>,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Cache"
|
||||
description="Answer reuse, its lifetime, and what survives a restart."
|
||||
actions={
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={RefreshCw}
|
||||
onClick={() => setConfirmFlush(true)}
|
||||
loading={flush.pending}
|
||||
>
|
||||
Flush cache
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard
|
||||
label="Entries"
|
||||
value={formatNumber(data?.entries ?? 0)}
|
||||
icon={Database}
|
||||
hint={data?.enabled === false ? 'cache disabled' : undefined}
|
||||
loading={loading && data === null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Size"
|
||||
value={formatBytes(data?.size ?? 0)}
|
||||
icon={HardDrive}
|
||||
hint={`${formatNumber(lookups)} lookups`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Shards"
|
||||
value={formatNumber(data?.shards ?? 0)}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Max TTL"
|
||||
value={formatMs(data?.max_ttl_ms ?? 0)}
|
||||
icon={Timer}
|
||||
hint={`min ${formatMs(data?.min_ttl_ms ?? 0)}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Serve stale"
|
||||
value={formatMs(data?.serve_stale_ms ?? 0)}
|
||||
icon={FileClock}
|
||||
hint={data?.serve_stale_ms ? 'upstream timeouts tolerated' : 'never serve stale'}
|
||||
tone={data?.serve_stale_ms ? 'default' : 'warn'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Evictions"
|
||||
value={formatNumber(data?.evictions ?? 0)}
|
||||
icon={Gauge}
|
||||
hint={`${formatNumber(data?.stale_hits ?? 0)} stale hits`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Hit rate"
|
||||
subtitle={`${formatRate(hitRate)} of ${formatNumber(lookups)} lookups served from memory`}
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<div className="relative h-48 w-48 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pie}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="62%"
|
||||
outerRadius="92%"
|
||||
paddingAngle={1}
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{pie.map((slice) => (
|
||||
<Cell key={slice.name} fill={slice.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip content={<ChartTip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="text-center">
|
||||
<p className="tabular text-xl font-semibold text-fg">{formatRate(hitRate, 0)}</p>
|
||||
<p className="text-xs text-fg-muted">hit rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="w-full min-w-0 space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ background: HITS }}
|
||||
/>
|
||||
<span className="text-fg-muted">Hits</span>
|
||||
<span className="tabular ml-auto font-mono text-xs">
|
||||
{formatNumber(hits)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ background: MISSES }}
|
||||
/>
|
||||
<span className="text-fg-muted">Misses</span>
|
||||
<span className="tabular ml-auto font-mono text-xs">
|
||||
{formatNumber(misses)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 border-t border-line pt-2 text-xs text-fg-muted">
|
||||
<span>Negative TTL</span>
|
||||
<span className="tabular ml-auto font-mono">
|
||||
{formatMs(data?.negative_ttl_ms ?? 0)}
|
||||
</span>
|
||||
</li>
|
||||
{lookups === 0 && (
|
||||
<li className="text-xs text-fg-subtle">
|
||||
Nothing has been looked up since the daemon started.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Persisted file"
|
||||
subtitle={persisted.enabled ? 'Snapshots survive a restart' : 'Snapshots are turned off'}
|
||||
actions={
|
||||
<Badge tone={persisted.enabled ? 'ok' : 'muted'}>
|
||||
{persisted.enabled ? 'enabled' : 'disabled'}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<div className="col-span-2 min-w-0 sm:col-span-3">
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Path</dt>
|
||||
<dd className="mt-0.5 truncate font-mono text-xs" title={persisted.path}>
|
||||
{persisted.path || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Size</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatBytes(persisted.bytes ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Entries</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatNumber(persisted.entries ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Writes</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatNumber(persisted.writes ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Errors</dt>
|
||||
<dd
|
||||
className={
|
||||
persistErrors > 0
|
||||
? 'tabular mt-0.5 font-mono text-sm text-danger'
|
||||
: 'tabular mt-0.5 font-mono text-sm'
|
||||
}
|
||||
>
|
||||
{formatNumber(persistErrors)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Last snapshot</dt>
|
||||
<dd className="mt-0.5 text-sm">{formatRelative(persisted.last_snapshot)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{persistMessage !== '' && (
|
||||
<p className="mt-3 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-xs text-danger">
|
||||
{persistMessage}
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Most requested"
|
||||
subtitle={`Top ${TOP_LIMIT} names by lookups`}
|
||||
padded={false}
|
||||
actions={<Badge tone="muted">auto-refresh every 15s</Badge>}
|
||||
>
|
||||
{topError !== null ? (
|
||||
<CardBody>
|
||||
<p className="text-sm text-danger">{topError}</p>
|
||||
</CardBody>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={topColumns}
|
||||
rows={topRows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={topLoading && entries.length === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="The cache is empty"
|
||||
description="Nothing has been cached yet, so there is no ranking to show."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmFlush}
|
||||
onClose={() => setConfirmFlush(false)}
|
||||
onConfirm={() => void onFlush()}
|
||||
title="Flush the cache?"
|
||||
message="Every cached answer is dropped. The next query for each name goes back to the upstreams, so expect a burst of upstream traffic."
|
||||
confirmLabel="Flush cache"
|
||||
destructive
|
||||
pending={flush.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+23
-23
@@ -10,10 +10,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
|
||||
|
||||
import { ApiError, getClients, putClients } from '../../lib/api'
|
||||
import type { ClientGroup, ClientMatch } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { fromLines, toLines } from '../../lib/format'
|
||||
import { ApiError, getClients, putClients } from '../lib/api'
|
||||
import type { ClientGroup, ClientMatch } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { fromLines, toLines } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -28,19 +28,19 @@ import {
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import type { ToastOptions } from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
/**
|
||||
* One line for every mutation on this page: the message plus the tone it
|
||||
* earned. `fail` is the danger tone, `ok` the success one.
|
||||
*/
|
||||
function notify(
|
||||
toast: (options: ToastOptions) => void,
|
||||
kind: 'ok' | 'fail',
|
||||
message: string,
|
||||
): void {
|
||||
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
|
||||
}
|
||||
|
||||
const POLICIES = [
|
||||
@@ -173,11 +173,11 @@ function fieldLeaf(path: string): string {
|
||||
}
|
||||
|
||||
export default function Clients() {
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const { data, error, loading, refetch } = useApi(() => getClients(), [])
|
||||
|
||||
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
|
||||
const [defaultPolicy, setDefaultPolicy] = useState<string>('')
|
||||
const [defaultPolicy] = useState<string>('')
|
||||
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
|
||||
@@ -302,7 +302,7 @@ export default function Clients() {
|
||||
title="Clients"
|
||||
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
|
||||
actions={
|
||||
<Button icon={<Plus size={14} />} onClick={openNew}>
|
||||
<Button icon={Plus} onClick={openNew}>
|
||||
Add group
|
||||
</Button>
|
||||
}
|
||||
@@ -322,11 +322,11 @@ export default function Clients() {
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users size={20} />}
|
||||
icon={Users}
|
||||
title="No client groups"
|
||||
description="Without a group every client gets the default policy."
|
||||
action={
|
||||
<Button icon={<Plus size={14} />} onClick={openNew}>
|
||||
<Button icon={Plus} onClick={openNew}>
|
||||
Add the first group
|
||||
</Button>
|
||||
}
|
||||
@@ -362,10 +362,10 @@ export default function Clients() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
|
||||
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
|
||||
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
|
||||
<IconButton icon={Copy} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
|
||||
<IconButton
|
||||
icon={<Trash2 size={14} />}
|
||||
icon={Trash2}
|
||||
label={`delete ${group.name}`}
|
||||
variant="ghost"
|
||||
onClick={() => setPendingDelete(i)}
|
||||
|
||||
+11
-26
@@ -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<DashboardData> & {
|
||||
@@ -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() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<RefreshCw size={14} />}
|
||||
icon={RefreshCw}
|
||||
onClick={refetch}
|
||||
loading={loading}
|
||||
>
|
||||
@@ -251,26 +236,26 @@ export default function Dashboard() {
|
||||
label="Queries / s"
|
||||
value={formatCompact(d.queries_per_sec ?? 0)}
|
||||
hint={`${formatNumber(d.queries_total ?? 0)} total`}
|
||||
icon={<Activity size={16} />}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cache hit rate"
|
||||
value={formatRate(hitRate)}
|
||||
hint="higher is better"
|
||||
icon={<Gauge size={16} />}
|
||||
icon={Gauge}
|
||||
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={formatPercent(d.blocked_pct ?? 0)}
|
||||
hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
|
||||
icon={<Ban size={16} />}
|
||||
icon={Ban}
|
||||
/>
|
||||
<StatCard
|
||||
label={latencyLabel}
|
||||
value={latencyValue != null ? formatMs(latencyValue) : '—'}
|
||||
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
|
||||
icon={<Timer size={16} />}
|
||||
icon={Timer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+22
-21
@@ -10,15 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { addDomain, deleteDomain, getDomains } from '../../lib/api'
|
||||
import type { AddDomainInput } from '../../lib/api'
|
||||
import type { DomainItem } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { useDebounced } from '../../hooks/useDebounced'
|
||||
import { formatNumber, formatRelative, toLines } from '../../lib/format'
|
||||
import { addDomain, deleteDomain, getDomains } from '../lib/api'
|
||||
import type { AddDomainInput } from '../lib/api'
|
||||
import type { DomainItem } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useDebounced } from '../hooks/useDebounced'
|
||||
import { formatNumber, formatRelative, toLines } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
@@ -30,21 +31,21 @@ import {
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import type { ToastOptions } from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
/**
|
||||
* One line for every mutation on this page: the message plus the tone it
|
||||
* earned. `fail` is the danger tone, `ok` the success one.
|
||||
*/
|
||||
function notify(
|
||||
toast: (options: ToastOptions) => void,
|
||||
kind: 'ok' | 'fail',
|
||||
message: string,
|
||||
): void {
|
||||
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
@@ -88,7 +89,7 @@ type AddForm = {
|
||||
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
|
||||
|
||||
export default function Domains() {
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const [search, setSearch] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [list, setList] = useState('')
|
||||
@@ -220,7 +221,7 @@ export default function Domains() {
|
||||
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
|
||||
delete: (
|
||||
<IconButton
|
||||
icon={<Trash2 size={14} />}
|
||||
icon={Trash2}
|
||||
label={`delete ${item.name}`}
|
||||
variant="ghost"
|
||||
onClick={() => setPendingDelete(item)}
|
||||
@@ -234,7 +235,7 @@ export default function Domains() {
|
||||
title="Domains"
|
||||
description="Inline allow and block rules, checked before the downloaded lists."
|
||||
actions={
|
||||
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}>
|
||||
<Button icon={Plus} onClick={() => setAddOpen(true)}>
|
||||
Add rule
|
||||
</Button>
|
||||
}
|
||||
@@ -245,7 +246,7 @@ export default function Domains() {
|
||||
<Input
|
||||
label="Search"
|
||||
placeholder="ads.example.com"
|
||||
icon={<Search size={14} />}
|
||||
icon={Search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Login — the only page that renders outside AppShell.
|
||||
*
|
||||
* The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the
|
||||
* form is built from what `/api/whoami` reports rather than hard-coded. A 401
|
||||
* from that probe is expected (that is why the visitor is here) and is not
|
||||
* shown as an error.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { ApiError, login, whoami } from '../lib/api'
|
||||
import type { WhoamiResponse } from '../lib/api'
|
||||
import { Button, Card, CardBody, Input } from '../components/ui'
|
||||
import { Spinner } from '../components/ui'
|
||||
|
||||
/** "apikey" -> one field, "basic" -> two, anything else -> one field. */
|
||||
function wantsPassword(mode: string): boolean {
|
||||
return mode === 'basic'
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [params] = useSearchParams()
|
||||
const next = params.get('next') ?? '/'
|
||||
|
||||
const [mode, setMode] = useState<string | null>(null)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
whoami()
|
||||
.then((who: WhoamiResponse) => {
|
||||
if (live) setMode(who.auth_mode ?? 'apikey')
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated probes and older daemons both land here: show the
|
||||
// form rather than an error, the submit will say what is wrong.
|
||||
if (live) setMode('apikey')
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (pending) return
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
if (wantsPassword(mode ?? 'apikey')) {
|
||||
await login({ username, password })
|
||||
} else {
|
||||
await login({ api_key: apiKey })
|
||||
}
|
||||
// A full navigation: the shell reads whoami and the token on boot.
|
||||
window.location.assign(next.startsWith('/') ? next : '/')
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'login failed',
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMode = wantsPassword(mode ?? 'apikey')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-canvas px-4 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-5 flex items-center justify-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-accent-soft text-accent">
|
||||
<Globe size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight text-fg">fancy-dns</span>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Sign in"
|
||||
subtitle={
|
||||
mode === null
|
||||
? 'Checking the authentication mode…'
|
||||
: passwordMode
|
||||
? 'This node uses username and password.'
|
||||
: 'This node uses an API key.'
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
|
||||
{passwordMode ? (
|
||||
<>
|
||||
<Input
|
||||
label="Username"
|
||||
icon={ShieldCheck}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
icon={KeyRound}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
label="API key"
|
||||
type="password"
|
||||
icon={KeyRound}
|
||||
placeholder="paste the node's API key"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-danger/40 bg-danger-soft px-3 py-2 text-xs text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={pending ? undefined : LogIn}
|
||||
loading={pending}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{pending ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
|
||||
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
Scripts can skip this page: send the same credential as an{' '}
|
||||
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
|
||||
<span className="font-mono text-fg-muted">
|
||||
curl -H "X-API-Key: $KEY" http://node:8080/api/dashboard
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{pending && (
|
||||
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" />
|
||||
waiting for the node
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Logs — the query log, and the live tail of it.
|
||||
*
|
||||
* One table, two sources: the paged `/api/logs` result and the SSE stream from
|
||||
* `/api/logs/stream`. While LIVE is on, streamed entries are prepended and run
|
||||
* through the same filters client-side, so a live row can never contradict what
|
||||
* the filter bar says. Rendering is capped: a busy node emits far faster than a
|
||||
* browser can lay out rows, and an uncapped list is the tab's worst enemy.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Eraser, Radio, Search, Signal, WifiOff } from 'lucide-react'
|
||||
|
||||
import { clearLogs, getLogs, streamLogs } from '../lib/api'
|
||||
import type { LogFilters, LogItem, LiveStats } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../hooks/useDebounced'
|
||||
import {
|
||||
formatClock,
|
||||
formatCompact,
|
||||
formatMs,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatRcode,
|
||||
} from '../lib/format'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
const PER_PAGE = 50
|
||||
/** Hard cap on rendered rows, live tail included. */
|
||||
const MAX_ROWS = 300
|
||||
const REFRESH_LIVE_MS = 3000
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: '', label: 'All actions' },
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'cached', label: 'Cached' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'drop', label: 'Drop' },
|
||||
]
|
||||
|
||||
/** The filter set, reduced to what both the server and the live tail need. */
|
||||
interface Filter {
|
||||
q: string
|
||||
action: string
|
||||
client: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
|
||||
switch (action) {
|
||||
case 'allow':
|
||||
return 'default'
|
||||
case 'cached':
|
||||
return 'info'
|
||||
case 'block':
|
||||
return 'warn'
|
||||
case 'error':
|
||||
return 'danger'
|
||||
case 'drop':
|
||||
return 'muted'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
/** The server filters; the live tail re-implements the same rules. */
|
||||
function matches(entry: LogItem, filter: Filter): boolean {
|
||||
if (filter.action !== '' && entry.action !== filter.action) return false
|
||||
if (filter.blocked && entry.action !== 'block') return false
|
||||
|
||||
if (filter.client !== '') {
|
||||
const needle = filter.client.toLowerCase()
|
||||
const hay = `${entry.client} ${entry.client_group}`.toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
|
||||
if (filter.q !== '') {
|
||||
const needle = filter.q.toLowerCase()
|
||||
const hay = [
|
||||
entry.name,
|
||||
entry.list,
|
||||
entry.upstream,
|
||||
entry.client,
|
||||
entry.client_group,
|
||||
entry.action,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Older daemons omit stats fields; a missing counter reads as zero, not NaN. */
|
||||
function liveStats(stats: LiveStats): LiveStats {
|
||||
return {
|
||||
qps: Number(stats.qps) || 0,
|
||||
blocked_pct: Number(stats.blocked_pct) || 0,
|
||||
cache_hit_rate: Number(stats.cache_hit_rate) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const { toast } = useToast()
|
||||
const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void =>
|
||||
toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' })
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [client, setClient] = useState('')
|
||||
const [blockedOnly, setBlockedOnly] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const q = useDebounced(search, 300)
|
||||
|
||||
const [live, setLive] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [streamError, setStreamError] = useState<string | null>(null)
|
||||
const [liveRows, setLiveRows] = useState<LogItem[]>([])
|
||||
const [stats, setStats] = useState<LiveStats | null>(null)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
|
||||
const clear = useAsyncAction(async () => clearLogs())
|
||||
|
||||
// Every filter change invalidates both the current page and the live tail.
|
||||
const applyFilter = (): void => {
|
||||
setPage(1)
|
||||
setLiveRows([])
|
||||
}
|
||||
|
||||
const filters = useMemo<LogFilters>(
|
||||
() => ({
|
||||
q: q.trim() || undefined,
|
||||
action: action === '' ? undefined : action,
|
||||
client: client.trim() || undefined,
|
||||
blocked: blockedOnly ? true : undefined,
|
||||
limit: PER_PAGE,
|
||||
offset: (page - 1) * PER_PAGE,
|
||||
}),
|
||||
[q, action, client, blockedOnly, page],
|
||||
)
|
||||
|
||||
const { data, error, loading, refetch } = useApi(() => getLogs(filters), [filters])
|
||||
useAutoRefresh(refetch, REFRESH_LIVE_MS, live)
|
||||
|
||||
// The stream outlives every filter change, so it reads the current filter
|
||||
// through a ref instead of resubscribing on each keystroke.
|
||||
const filterRef = useRef<Filter>({ q: '', action: '', client: '', blocked: false })
|
||||
useEffect(() => {
|
||||
filterRef.current = { q: q.trim(), action, client: client.trim(), blocked: blockedOnly }
|
||||
}, [q, action, client, blockedOnly])
|
||||
|
||||
const toggleLive = (next: boolean): void => {
|
||||
setLiveRows([])
|
||||
setStats(null)
|
||||
setConnected(false)
|
||||
setStreamError(null)
|
||||
setLive(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return
|
||||
const stop = streamLogs({
|
||||
onEntry: (entry) => {
|
||||
if (!matches(entry, filterRef.current)) return
|
||||
setLiveRows((prev) => [entry, ...prev].slice(0, MAX_ROWS))
|
||||
},
|
||||
onStats: (next) => setStats(liveStats(next)),
|
||||
onReady: () => setConnected(true),
|
||||
onOpen: () => {
|
||||
setConnected(true)
|
||||
setStreamError(null)
|
||||
},
|
||||
onError: (message) => {
|
||||
setConnected(false)
|
||||
setStreamError(message)
|
||||
},
|
||||
})
|
||||
return stop
|
||||
}, [live])
|
||||
|
||||
const items: LogItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const total = data?.total ?? items.length
|
||||
const rows = useMemo<LogItem[]>(
|
||||
() => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS),
|
||||
[liveRows, items],
|
||||
)
|
||||
|
||||
const onClear = async (): Promise<void> => {
|
||||
const res = await clear.run()
|
||||
if (res === null) {
|
||||
notify('Could not clear the log', clear.error ?? 'the server rejected the request')
|
||||
return
|
||||
}
|
||||
setConfirmClear(false)
|
||||
setLiveRows([])
|
||||
notify('Log cleared', 'the server dropped every buffered entry', 'ok')
|
||||
refetch()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const columns = [
|
||||
{ key: 'ts', header: 'Time', width: '9%' },
|
||||
{ key: 'client', header: 'Client', width: '13%' },
|
||||
{ key: 'group', header: 'Group', width: '10%' },
|
||||
{ key: 'name', header: 'Name', width: '24%' },
|
||||
{ key: 'qtype', header: 'Type', width: '6%' },
|
||||
{ key: 'rcode', header: 'Rcode', width: '12%' },
|
||||
{ key: 'action', header: 'Action', width: '8%' },
|
||||
{ key: 'duration', header: 'Time', width: '8%', align: 'right' as const },
|
||||
{ key: 'upstream', header: 'Upstream', width: '10%' },
|
||||
{ key: 'list', header: 'List', width: '12%' },
|
||||
]
|
||||
|
||||
const tableRows: Row[] = rows.map((entry, index) => ({
|
||||
__key: `${entry.id}-${entry.ts}-${index}`,
|
||||
ts: <span className="tabular font-mono text-xs text-fg-muted">{formatClock(entry.ts)}</span>,
|
||||
client: (
|
||||
<span className="block max-w-[180px] truncate font-mono text-xs">
|
||||
{entry.client || '—'}
|
||||
</span>
|
||||
),
|
||||
group: entry.client_group ? (
|
||||
<Badge tone="muted">{entry.client_group}</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
name: (
|
||||
<span className="block max-w-[320px] truncate font-mono text-xs" title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
),
|
||||
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
|
||||
rcode: <span className="font-mono text-xs text-fg-muted">{formatRcode(entry.rcode)}</span>,
|
||||
action: <Badge tone={actionTone(entry.action)}>{entry.action || 'unknown'}</Badge>,
|
||||
duration: <span className="font-mono text-xs">{formatMs(entry.duration_ms)}</span>,
|
||||
upstream: (
|
||||
<span className="block max-w-[160px] truncate font-mono text-xs text-fg-muted">
|
||||
{entry.upstream || '—'}
|
||||
</span>
|
||||
),
|
||||
list: (
|
||||
<span className="block max-w-[200px] truncate text-xs text-fg-muted" title={entry.list}>
|
||||
{entry.list || '—'}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const qps = stats?.qps ?? 0
|
||||
const blockedPct = stats?.blocked_pct ?? 0
|
||||
const hitRate = stats?.cache_hit_rate ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
description="Every query the daemon answered, with the rule that decided it."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
tone={live ? (connected ? 'ok' : 'danger') : 'muted'}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
live ? (connected ? 'animate-pulse-soft bg-ok' : 'animate-pulse-soft bg-danger') : 'bg-fg-subtle',
|
||||
)}
|
||||
/>
|
||||
{live ? (connected ? 'LIVE' : 'DISCONNECTED') : 'PAUSED'}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={live}
|
||||
onChange={toggleLive}
|
||||
label="Live tail"
|
||||
description="Stream new entries over SSE"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Eraser}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
loading={clear.pending}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{live && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs shadow-panel">
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||
<Signal size={13} aria-hidden="true" />
|
||||
<span className="tabular font-mono text-fg">{formatCompact(qps)}</span> qps
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
<span className="tabular font-mono text-fg">{formatPercent(blockedPct)}</span>{' '}
|
||||
blocked
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
<span className="tabular font-mono text-fg">{formatRate(hitRate)}</span> cache hits
|
||||
</span>
|
||||
<span className="text-fg-subtle">sampled by the daemon every few seconds</span>
|
||||
{streamError !== null && (
|
||||
<span className="text-danger" title={streamError}>
|
||||
<WifiOff size={13} className="mr-1 inline align-[-2px]" aria-hidden="true" />
|
||||
{streamError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Filters" subtitle="Free text covers name, list, upstream and client.">
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
label="Search"
|
||||
icon={Search}
|
||||
placeholder="example.com, ads, 10.0.0.0/8"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
options={ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(event) => {
|
||||
setAction(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Client"
|
||||
placeholder="address or group name"
|
||||
value={client}
|
||||
onChange={(event) => {
|
||||
setClient(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-end pb-1">
|
||||
<Switch
|
||||
checked={blockedOnly}
|
||||
onChange={(next) => {
|
||||
setBlockedOnly(next)
|
||||
applyFilter()
|
||||
}}
|
||||
label="Blocked only"
|
||||
description="Hide allowed and cached answers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Query log"
|
||||
subtitle={`${rows.length} of ${formatNumber(total)} matching entries`}
|
||||
padded={false}
|
||||
actions={
|
||||
rows.length >= MAX_ROWS ? (
|
||||
<Badge tone="warn">capped at {MAX_ROWS} rows</Badge>
|
||||
) : (
|
||||
<Badge tone="muted">
|
||||
<Radio size={11} aria-hidden="true" />
|
||||
{live ? 'streaming' : 'paused'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={tableRows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={loading && items.length === 0 && liveRows.length === 0}
|
||||
maxHeight={640}
|
||||
empty="No query matched these filters."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
perPage={PER_PAGE}
|
||||
total={total}
|
||||
onPage={setPage}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onConfirm={() => void onClear()}
|
||||
title="Clear the query log?"
|
||||
message="Every buffered entry is dropped on the server. This cannot be undone, and the running log keeps filling from the next query."
|
||||
confirmLabel="Clear log"
|
||||
destructive
|
||||
pending={clear.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* NotFound — the catch-all route.
|
||||
*/
|
||||
|
||||
import { Compass, LayoutDashboard } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { Button, Card, CardBody, EmptyState } from '../components/ui'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EmptyState
|
||||
icon={Compass}
|
||||
title="404 — that page does not exist"
|
||||
description="The console has no route for this address. It may have been renamed, or the link may be stale."
|
||||
action={
|
||||
<Link to="/">
|
||||
<Button variant="primary" icon={LayoutDashboard}>
|
||||
Back to the dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card title="Available pages" subtitle="The console ships these routes">
|
||||
<CardBody>
|
||||
<ul className="grid grid-cols-1 gap-1.5 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
['/', 'Dashboard'],
|
||||
['/blocklists', 'Blocklists'],
|
||||
['/domains', 'Domains'],
|
||||
['/clients', 'Clients'],
|
||||
['/upstreams', 'Upstreams'],
|
||||
['/logs', 'Logs'],
|
||||
['/cache', 'Cache'],
|
||||
['/settings', 'Settings'],
|
||||
].map(([to, label]) => (
|
||||
<li key={to}>
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-baseline gap-2 rounded-lg px-2 py-1.5 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span className="font-mono text-xs text-fg-subtle">{to}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Settings — the YAML that the daemon was started from.
|
||||
*
|
||||
* The editor is deliberately plain: a line-numbered textarea rather than a
|
||||
* YAML widget, because the operator's file already has comments and ordering
|
||||
* that a form would silently destroy. Validation is server-side, so what is
|
||||
* reported here is exactly what the daemon would do with the file.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { UIEvent } from 'react'
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
FileCode2,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
flushCache,
|
||||
getSettings,
|
||||
putSettings,
|
||||
refreshBlocklists,
|
||||
reloadSettings,
|
||||
validateSettings,
|
||||
} from '../lib/api'
|
||||
import type { ConfigIssue } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { formatNumber } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
CopyButton,
|
||||
Spinner,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { LoadingBlock, PageError, PageHeader } from '../components/layout'
|
||||
|
||||
interface Report {
|
||||
errors: ConfigIssue[]
|
||||
warnings: ConfigIssue[]
|
||||
}
|
||||
|
||||
/** A validation list is only usable if every entry has a message. */
|
||||
function issues(list: ConfigIssue[] | undefined): ConfigIssue[] {
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.filter((item) => item != null && typeof item.message === 'string' && item.message !== '')
|
||||
}
|
||||
|
||||
function IssueList({ issues: list, tone }: { issues: ConfigIssue[]; tone: 'danger' | 'warn' }) {
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{list.map((issue, index) => (
|
||||
<li key={`${issue.field}-${index}`} className="flex min-w-0 items-start gap-2 text-xs">
|
||||
{tone === 'danger' ? (
|
||||
<TriangleAlert size={13} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
) : (
|
||||
<Info size={13} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
{issue.field !== '' && (
|
||||
<span className="mr-1.5 font-mono text-fg-subtle">{issue.field}</span>
|
||||
)}
|
||||
<span className={tone === 'danger' ? 'text-danger' : 'text-fg-muted'}>
|
||||
{issue.message}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { toast } = useToast()
|
||||
const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void =>
|
||||
toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' })
|
||||
|
||||
const { data, error, loading, refetch } = useApi(() => getSettings(), [])
|
||||
// The draft is keyed by the version it was started from: once the server
|
||||
// reports a different version the edit is dropped, so a refetch can never
|
||||
// stomp on an editor that is mid-change.
|
||||
const [draft, setDraft] = useState<{ version: string; text: string } | null>(null)
|
||||
const gutter = useRef<HTMLDivElement | null>(null)
|
||||
const [report, setReport] = useState<Report | null>(null)
|
||||
const [confirmReload, setConfirmReload] = useState(false)
|
||||
|
||||
const validate = useAsyncAction(async (yaml: string) => validateSettings(yaml))
|
||||
const save = useAsyncAction(async (yaml: string) => putSettings(yaml))
|
||||
const reload = useAsyncAction(async () => reloadSettings())
|
||||
const flush = useAsyncAction(async () => flushCache())
|
||||
const refreshLists = useAsyncAction(async () => refreshBlocklists())
|
||||
|
||||
const pristine = data?.config_yaml ?? ''
|
||||
const version = data?.version ?? ''
|
||||
const value = draft !== null && draft.version === version ? draft.text : pristine
|
||||
const dirty = data !== null && value !== pristine
|
||||
const writable = data?.writable !== false
|
||||
const lineCount = useMemo(() => value.split('\n').length, [value])
|
||||
const gutterLines = useMemo(() => Array.from({ length: lineCount }, (_, i) => i + 1), [lineCount])
|
||||
|
||||
// The gutter is a plain div; keep its scroll offset glued to the textarea's.
|
||||
const onScroll = (event: UIEvent<HTMLTextAreaElement>): void => {
|
||||
const element = gutter.current
|
||||
if (element !== null) element.scrollTop = event.currentTarget.scrollTop
|
||||
}
|
||||
|
||||
const onValidate = async (): Promise<void> => {
|
||||
const res = await validate.run(value)
|
||||
if (res === null) {
|
||||
setReport(null)
|
||||
notify('Validation failed', validate.error ?? 'the server could not check this file')
|
||||
return
|
||||
}
|
||||
const found: Report = { errors: issues(res.errors), warnings: issues(res.warnings) }
|
||||
setReport(found)
|
||||
if (found.errors.length === 0) {
|
||||
notify(
|
||||
'Configuration is valid',
|
||||
found.warnings.length > 0
|
||||
? `${found.warnings.length} ${found.warnings.length === 1 ? 'warning' : 'warnings'}`
|
||||
: 'the daemon would accept this file',
|
||||
'ok',
|
||||
)
|
||||
return
|
||||
}
|
||||
notify(
|
||||
'Configuration is not valid',
|
||||
`${found.errors.length} ${found.errors.length === 1 ? 'error' : 'errors'}, ${found.warnings.length} warnings`,
|
||||
)
|
||||
}
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
const res = await save.run(value)
|
||||
if (res === null) {
|
||||
notify('Could not save', save.error ?? 'the daemon rejected the write')
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(res.warnings) ? res.warnings : []
|
||||
notify(
|
||||
'Configuration saved',
|
||||
list.length > 0
|
||||
? `${list.length} ${list.length === 1 ? 'warning' : 'warnings'}: ${list.join('; ')}`
|
||||
: `version ${res.version || 'unknown'}`,
|
||||
'ok',
|
||||
)
|
||||
setDraft(null)
|
||||
setReport(null)
|
||||
refetch()
|
||||
}
|
||||
|
||||
const onReload = async (): Promise<void> => {
|
||||
const res = await reload.run()
|
||||
setConfirmReload(false)
|
||||
if (res === null) {
|
||||
notify('Reload failed', reload.error ?? 'the daemon kept the running configuration')
|
||||
return
|
||||
}
|
||||
setDraft(null)
|
||||
setReport(null)
|
||||
notify('Configuration reloaded', `running version ${res.version || 'unknown'}`, 'ok')
|
||||
refetch()
|
||||
}
|
||||
|
||||
const onFlush = async (): Promise<void> => {
|
||||
const res = await flush.run()
|
||||
if (res === null) {
|
||||
notify('Could not flush the cache', flush.error ?? 'the server rejected the request')
|
||||
return
|
||||
}
|
||||
notify(
|
||||
'Cache flushed',
|
||||
`${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`,
|
||||
'ok',
|
||||
)
|
||||
}
|
||||
|
||||
const onRefreshLists = async (): Promise<void> => {
|
||||
const res = await refreshLists.run()
|
||||
if (res === null) {
|
||||
notify('Refresh failed', refreshLists.error ?? 'the server rejected the request')
|
||||
return
|
||||
}
|
||||
notify('Refresh started', `${res.names?.length ?? 0} sources queued`, 'ok')
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
if (loading && data === null) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Settings" description="The configuration the daemon is running." />
|
||||
<Card>
|
||||
<CardBody>
|
||||
<LoadingBlock rows={8} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const effective = data?.effective_yaml ?? ''
|
||||
const defaults = data?.defaults_yaml ?? ''
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="The configuration the daemon is running, and the file behind it."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={writable ? 'ok' : 'warn'}>{writable ? 'writable' : 'read-only'}</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={RefreshCw}
|
||||
onClick={() => setConfirmReload(true)}
|
||||
loading={reload.pending}
|
||||
disabled={data?.reload_available === false}
|
||||
>
|
||||
Reload from disk
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-fg-muted">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<FileCode2 size={12} aria-hidden="true" />
|
||||
<span className="truncate font-mono" title={data?.path}>
|
||||
{data?.path || 'unknown path'}
|
||||
</span>
|
||||
</span>
|
||||
<span>version {data?.version || 'unknown'}</span>
|
||||
{data?.reload_available === false && (
|
||||
<span>the daemon cannot reload this file at runtime</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data?.last_error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-danger/40 bg-danger-soft px-4 py-3"
|
||||
>
|
||||
<ShieldAlert size={18} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">The daemon reported a config error</p>
|
||||
<p className="mt-0.5 font-mono text-xs break-words text-danger">{data.last_error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Configuration file"
|
||||
subtitle={`${lineCount} lines, as stored on disk`}
|
||||
padded={false}
|
||||
actions={<Badge tone={dirty ? 'warn' : 'muted'}>{dirty ? 'modified' : 'in sync'}</Badge>}
|
||||
>
|
||||
<div className="flex min-w-0 items-stretch">
|
||||
<div
|
||||
ref={gutter}
|
||||
aria-hidden="true"
|
||||
className="max-h-[520px] w-11 shrink-0 overflow-hidden border-r border-line bg-surface-2 py-2 pr-2 text-right font-mono text-xs leading-[1.625] text-fg-subtle select-none"
|
||||
>
|
||||
{gutterLines.map((line) => (
|
||||
<div key={line}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
monospace
|
||||
value={value}
|
||||
onChange={(event) => setDraft({ version, text: event.currentTarget.value })}
|
||||
onScroll={onScroll}
|
||||
rows={26}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
aria-label="Configuration YAML"
|
||||
className="h-[520px] min-h-[520px] rounded-none border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Card
|
||||
title="Validate and save"
|
||||
subtitle="The daemon parses the exact text on the left."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={CheckCircle2}
|
||||
onClick={() => void onValidate()}
|
||||
loading={validate.pending}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={Save}
|
||||
onClick={() => void onSave()}
|
||||
loading={save.pending}
|
||||
disabled={!writable || !dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
{!writable && (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-fg">
|
||||
<Ban size={14} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
|
||||
<span>
|
||||
This daemon reports the config file as read-only, so Save is disabled. Edit{' '}
|
||||
<span className="font-mono">{data?.path || 'the file'}</span> on the host and
|
||||
use Reload from disk.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{writable && !dirty && (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Nothing to save: the editor matches the file on disk.
|
||||
</p>
|
||||
)}
|
||||
{validate.pending && (
|
||||
<p className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" />
|
||||
validating {lineCount} lines…
|
||||
</p>
|
||||
)}
|
||||
{validate.error !== null && <p className="text-xs text-danger">{validate.error}</p>}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Validation report"
|
||||
subtitle={
|
||||
report === null
|
||||
? 'Nothing validated yet'
|
||||
: `${report.errors.length} ${report.errors.length === 1 ? 'error' : 'errors'}, ${report.warnings.length} ${report.warnings.length === 1 ? 'warning' : 'warnings'}`
|
||||
}
|
||||
actions={
|
||||
report === null ? null : (
|
||||
<Badge tone={report.errors.length === 0 ? 'ok' : 'danger'}>
|
||||
{report.errors.length === 0 ? 'valid' : 'invalid'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
{report === null ? (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Press Validate to parse the current text against the daemon's schema.
|
||||
</p>
|
||||
) : report.errors.length === 0 && report.warnings.length === 0 ? (
|
||||
<p className="text-xs text-ok">No errors and no warnings.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{report.errors.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-danger">Errors</p>
|
||||
<IssueList issues={report.errors} tone="danger" />
|
||||
</div>
|
||||
)}
|
||||
{report.warnings.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-warn">Warnings</p>
|
||||
<IssueList issues={report.warnings} tone="warn" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Effective configuration"
|
||||
subtitle="Your file with every default filled in"
|
||||
actions={<CopyButton value={effective} label="Copy effective config" />}
|
||||
>
|
||||
<CardBody>
|
||||
<details>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
|
||||
<FileCode2 size={14} aria-hidden="true" />
|
||||
Show the effective configuration
|
||||
<span className="text-xs text-fg-subtle">
|
||||
{effective === '' ? '' : `${effective.split('\n').length} lines`}
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
|
||||
{effective || 'The server sent no effective configuration.'}
|
||||
</pre>
|
||||
</details>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Defaults"
|
||||
subtitle="What the daemon falls back to for anything you leave out"
|
||||
actions={<CopyButton value={defaults} label="Copy defaults" />}
|
||||
>
|
||||
<CardBody>
|
||||
<details>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
Show defaults
|
||||
<span className="text-xs text-fg-subtle">read-only</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
|
||||
{defaults || 'The server sent no defaults.'}
|
||||
</pre>
|
||||
</details>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Quick actions" subtitle="The two things operators reach for from here">
|
||||
<CardBody>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
icon={Database}
|
||||
variant="outline"
|
||||
onClick={() => void onFlush()}
|
||||
loading={flush.pending}
|
||||
>
|
||||
Flush cache
|
||||
</Button>
|
||||
<Button
|
||||
icon={RefreshCw}
|
||||
variant="outline"
|
||||
onClick={() => void onRefreshLists()}
|
||||
loading={refreshLists.pending}
|
||||
>
|
||||
Refresh blocklists
|
||||
</Button>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
Both act on the running daemon; neither writes the config file.
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReload}
|
||||
onClose={() => setConfirmReload(false)}
|
||||
onConfirm={() => void onReload()}
|
||||
title="Reload the configuration from disk?"
|
||||
message={
|
||||
<>
|
||||
The daemon re-reads <span className="font-mono">{data?.path || 'its config'}</span>{' '}
|
||||
and applies it to the running process. In-flight queries finish on the old
|
||||
configuration; the next query uses the new one.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Reload"
|
||||
pending={reload.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
-27
@@ -20,10 +20,10 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { getUpstreams, putUpstreams } from '../../lib/api'
|
||||
import type { UpstreamBootstrap, UpstreamServer } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { formatLatency, formatMs, formatNumber, formatRate } from '../../lib/format'
|
||||
import { getUpstreams, putUpstreams } from '../lib/api'
|
||||
import type { UpstreamBootstrap, UpstreamHealth, UpstreamServer } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { formatLatency, formatMs, formatNumber, formatRate } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -40,21 +40,21 @@ import {
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import type { ToastOptions } from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
/**
|
||||
* One line for every mutation on this page: the message plus the tone it
|
||||
* earned. `fail` is the danger tone, `ok` the success one.
|
||||
*/
|
||||
function notify(
|
||||
toast: (options: ToastOptions) => void,
|
||||
kind: 'ok' | 'fail',
|
||||
message: string,
|
||||
): void {
|
||||
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
|
||||
}
|
||||
|
||||
const POLICIES = [
|
||||
@@ -167,7 +167,7 @@ function splitList(value: string): string[] {
|
||||
}
|
||||
|
||||
export default function Upstreams() {
|
||||
const toast = useToast()
|
||||
const { toast } = useToast()
|
||||
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
@@ -210,10 +210,15 @@ export default function Upstreams() {
|
||||
insecure_skip_verify: s.insecure_skip_verify,
|
||||
weight: s.weight,
|
||||
comment: s.comment,
|
||||
health: s.health,
|
||||
health: s.health as UpstreamHealth,
|
||||
latency_ms: s.latency_ms,
|
||||
fail_rate: s.fail_rate,
|
||||
breaker_open: s.breaker_open,
|
||||
// Runtime counters are not part of a write: the server owns them and
|
||||
// ignores these fields on PUT /upstreams.
|
||||
queries: 0,
|
||||
avg_ms: 0,
|
||||
p95_ms: 0,
|
||||
})),
|
||||
bootstraps: working.bootstraps.map((b) => ({
|
||||
name: b.name.trim(),
|
||||
@@ -314,10 +319,10 @@ export default function Upstreams() {
|
||||
comment: <span className="block max-w-[160px] truncate text-xs text-slate-500">{s.comment}</span>,
|
||||
order: (
|
||||
<span className="flex justify-end gap-0.5">
|
||||
<IconButton icon={<ArrowUp size={13} />} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} />
|
||||
<IconButton icon={<ArrowDown size={13} />} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} />
|
||||
<IconButton icon={<Pencil size={13} />} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} />
|
||||
<IconButton icon={<Trash2 size={13} />} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} />
|
||||
<IconButton icon={ArrowUp} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} />
|
||||
<IconButton icon={ArrowDown} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} />
|
||||
<IconButton icon={Pencil} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} />
|
||||
<IconButton icon={Trash2} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} />
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
@@ -331,13 +336,13 @@ export default function Upstreams() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<RotateCw size={14} />}
|
||||
icon={RotateCw}
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={!dirty}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button icon={<Save size={14} />} onClick={() => void commit()} loading={save.pending} disabled={!dirty}>
|
||||
<Button icon={Save} onClick={() => void commit()} loading={save.pending} disabled={!dirty}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -386,7 +391,7 @@ export default function Upstreams() {
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditing(EMPTY_SERVER)
|
||||
setEditorOpen(true)
|
||||
@@ -414,7 +419,7 @@ export default function Upstreams() {
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
icon={Plus}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...working,
|
||||
@@ -428,7 +433,7 @@ export default function Upstreams() {
|
||||
>
|
||||
{working.bootstraps.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Gauge size={20} />}
|
||||
icon={Gauge}
|
||||
title="No bootstrap resolvers"
|
||||
description="Without them, an upstream address must already be an IP."
|
||||
/>
|
||||
@@ -457,7 +462,7 @@ export default function Upstreams() {
|
||||
placeholder="24h"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<Trash2 size={14} />}
|
||||
icon={Trash2}
|
||||
label={`remove bootstrap ${b.name}`}
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
|
||||
+7
-1
@@ -20,7 +20,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// The Go binary embeds the console via //go:embed all:dist in
|
||||
// internal/web/web.go, so the build has to land there rather than in
|
||||
// web/dist: a bundle nobody embeds is a console nobody can load.
|
||||
outDir: '../internal/web/dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
// Hashed asset names let the server cache them immutably.
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user