more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5619
-423
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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
+6
-2
@@ -18,6 +18,7 @@ package cache
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
@@ -160,8 +161,11 @@ type Entry struct {
|
||||
StaleUntil time.Time
|
||||
// Bytes is the accounted size, used by the LRU and the size gauges.
|
||||
Bytes int
|
||||
// Hits counts serves since it was stored.
|
||||
Hits uint32
|
||||
// Hits counts serves since it was stored. It is atomic because an Entry is
|
||||
// shared by every goroutine that hits the key (the LRU hands out the same
|
||||
// pointer on purpose, so a hit never copies the message), and the
|
||||
// counter is bumped outside the shard lock on the hot path.
|
||||
Hits atomic.Uint32
|
||||
// Neg marks a cached NXDOMAIN or NODATA answer.
|
||||
Neg bool
|
||||
// Truncated records that the upstream answer did not fit in 512 bytes,
|
||||
|
||||
Vendored
+868
@@ -0,0 +1,868 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// fakeClock is a manually advanced clock so TTL, ageing and serve-stale
|
||||
// behaviour is tested without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} }
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// answer builds a packed A response for name.
|
||||
func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg {
|
||||
t.Helper()
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
|
||||
m.Response = true
|
||||
m.Answer = []dns.RR{&dns.A{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
|
||||
A: net.ParseIP(ip).To4(),
|
||||
}}
|
||||
return m
|
||||
}
|
||||
|
||||
func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) }
|
||||
|
||||
func TestLRUGetPutAndHitKinds(t *testing.T) {
|
||||
clk := newClock()
|
||||
l := NewLRU(100, 4)
|
||||
m := answer(t, "example.com", 60, "1.2.3.4")
|
||||
wire, err := m.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("pack: %v", err)
|
||||
}
|
||||
k := key("example.com")
|
||||
now := clk.Now()
|
||||
l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)})
|
||||
|
||||
if e, kind := l.Get(k, now); e == nil || kind != Fresh {
|
||||
t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind)
|
||||
}
|
||||
// One second later the entry is still fresh; after the TTL it is stale but
|
||||
// answerable until StaleUntil, and past that it is a miss and dropped.
|
||||
clk.Advance(59 * time.Second)
|
||||
if _, kind := l.Get(k, clk.Now()); kind != Fresh {
|
||||
t.Errorf("at TTL-1s: kind = %v, want Fresh", kind)
|
||||
}
|
||||
clk.Advance(2 * time.Second)
|
||||
e, kind := l.Get(k, clk.Now())
|
||||
if e == nil || kind != Stale {
|
||||
t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind)
|
||||
}
|
||||
if got := e.Hits.Load(); got != 3 {
|
||||
t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got)
|
||||
}
|
||||
clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s)
|
||||
if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss {
|
||||
t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind)
|
||||
}
|
||||
if got := l.Stats().Entries; got != 0 {
|
||||
t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got)
|
||||
}
|
||||
st := l.Stats()
|
||||
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 2 {
|
||||
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=2", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) {
|
||||
// One shard so the budget is exactly what we set: eviction order is then
|
||||
// fully determined and the test can assert it.
|
||||
l := NewLRU(4, 1)
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
put := func(name string) {
|
||||
l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8})
|
||||
}
|
||||
put("a.example.com")
|
||||
put("b.example.com")
|
||||
put("c.example.com")
|
||||
// Touch a so it is the most recent, then overflow the 4-entry budget.
|
||||
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
|
||||
t.Fatalf("a: kind = %v, want Fresh", kind)
|
||||
}
|
||||
put("d.example.com")
|
||||
put("e.example.com") // 5 inserts, budget 4
|
||||
|
||||
if got := l.Stats().Entries; got > 4 {
|
||||
t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got)
|
||||
}
|
||||
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
|
||||
t.Errorf("a was recently used, want it kept")
|
||||
}
|
||||
if _, kind := l.Get(key("b.example.com"), now); kind != Miss {
|
||||
t.Errorf("b was the least recently used, want it evicted")
|
||||
}
|
||||
if l.Stats().Evictions == 0 {
|
||||
t.Error("evictions = 0, want > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUDeleteWhereAndFlush(t *testing.T) {
|
||||
l := NewLRU(100, 2)
|
||||
now := newClock().Now()
|
||||
names := []string{"x.example.com", "y.example.com", "z.other.net"}
|
||||
for _, n := range names {
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
dropped := l.DeleteWhere(func(k Key, _ *Entry) bool {
|
||||
return k.Name == "x.example.com" || k.Name == "y.example.com"
|
||||
})
|
||||
if dropped != 2 {
|
||||
t.Errorf("DeleteWhere = %d, want 2", dropped)
|
||||
}
|
||||
if _, kind := l.Get(key("x.example.com"), now); kind != Miss {
|
||||
t.Error("x.example.com still present after DeleteWhere")
|
||||
}
|
||||
if _, kind := l.Get(key("z.other.net"), now); kind != Fresh {
|
||||
t.Error("z.other.net must survive an unrelated DeleteWhere")
|
||||
}
|
||||
l.Flush()
|
||||
if got := l.Stats().Entries; got != 0 {
|
||||
t.Errorf("entries after Flush = %d, want 0", got)
|
||||
}
|
||||
if l.Stats().Bytes != 0 {
|
||||
t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
|
||||
l := NewLRU(100, 4)
|
||||
now := newClock().Now()
|
||||
for i := range 200 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
seen := make(map[string]int, 200)
|
||||
l.Walk(func(k Key, _ *Entry) bool {
|
||||
seen[k.Name]++
|
||||
return true
|
||||
})
|
||||
if len(seen) != 200 {
|
||||
t.Fatalf("Walk visited %d names, want 200", len(seen))
|
||||
}
|
||||
for n, c := range seen {
|
||||
if c != 1 {
|
||||
t.Fatalf("%s visited %d times, want 1", n, c)
|
||||
}
|
||||
}
|
||||
// Returning false stops the walk.
|
||||
count := 0
|
||||
l.Walk(func(Key, *Entry) bool { count++; return false })
|
||||
if count != 1 {
|
||||
t.Errorf("Walk with stop visited %d entries, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) {
|
||||
l := NewLRU(100, 1)
|
||||
now := newClock().Now()
|
||||
for i := range 50 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
}
|
||||
l.SetCapacity(10, 1)
|
||||
if got := l.Stats().Entries; got > 10 {
|
||||
t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got)
|
||||
}
|
||||
if got := l.Stats().Shards; got != 1 {
|
||||
t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got)
|
||||
}
|
||||
// The cache must still work after a resize.
|
||||
l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh {
|
||||
t.Error("a Put after a resize must be readable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUConcurrentPutGetResize(t *testing.T) {
|
||||
// Exercises the resizing path: SetCapacity rewrites the per-shard budget
|
||||
// while other goroutines insert and read. Run with -race.
|
||||
l := NewLRU(512, 8)
|
||||
now := newClock().Now()
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
l.SetCapacity(200, 4)
|
||||
l.SetCapacity(4096, 16)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for g := range 8 {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := range 500 {
|
||||
n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com"
|
||||
k := key(n)
|
||||
l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
|
||||
e, _ := l.Get(k, now)
|
||||
if e != nil {
|
||||
_ = e.Hits.Load()
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
// Readers keep walking while the resizer runs.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
l.Walk(func(Key, *Entry) bool { return true })
|
||||
}
|
||||
}
|
||||
}()
|
||||
for g := 1; g < 8; g++ {
|
||||
_ = g
|
||||
}
|
||||
wg.Wait()
|
||||
close(stop)
|
||||
if l.Stats().Bytes < 0 {
|
||||
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPackUnpackRoundTrip(t *testing.T) {
|
||||
cases := []Key{
|
||||
{Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET},
|
||||
{Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true},
|
||||
{Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3},
|
||||
{Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7},
|
||||
}
|
||||
for _, k := range cases {
|
||||
buf, err := packKey(nil, k)
|
||||
if err != nil {
|
||||
t.Fatalf("packKey(%v): %v", k, err)
|
||||
}
|
||||
got, rest, err := unpackKey(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unpackKey(%v): %v", k, err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest))
|
||||
}
|
||||
if got != k {
|
||||
t.Errorf("round trip: got %+v, want %+v", got, k)
|
||||
}
|
||||
}
|
||||
// The wire name survives, so the canonical form comes back normalised.
|
||||
buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET})
|
||||
if err != nil {
|
||||
t.Fatalf("packKey: %v", err)
|
||||
}
|
||||
got, _, err := unpackKey(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unpackKey: %v", err)
|
||||
}
|
||||
if got.Name != "example.com" {
|
||||
t.Errorf("unpacked name = %q, want %q", got.Name, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPackRejectsBadInput(t *testing.T) {
|
||||
if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) {
|
||||
t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err)
|
||||
}
|
||||
if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil {
|
||||
t.Error("packKey(name with an empty label) error = nil, want an error")
|
||||
}
|
||||
if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) {
|
||||
t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err)
|
||||
}
|
||||
if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) {
|
||||
t.Errorf("unpackKey(empty) error = %v, want errBadKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryKeyCanonicalises(t *testing.T) {
|
||||
a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0)
|
||||
b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0)
|
||||
if a != b {
|
||||
t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b)
|
||||
}
|
||||
if a.Name != "example.com" {
|
||||
t.Errorf("canonical name = %q, want %q", a.Name, "example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryAgeTTL(t *testing.T) {
|
||||
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
e Entry
|
||||
now time.Time
|
||||
want uint32
|
||||
}{
|
||||
{"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70},
|
||||
{"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0},
|
||||
{"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0},
|
||||
{"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100},
|
||||
{"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.e.AgeTTL(tc.now); got != tc.want {
|
||||
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTTLFor(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
|
||||
soa := func(ttl uint32) *dns.SOA {
|
||||
return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl}
|
||||
}
|
||||
|
||||
t.Run("answers use the lowest TTL and clamp", func(t *testing.T) {
|
||||
m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
msg := answer(t, "a.example.com", 300, "1.2.3.4")
|
||||
msg.Answer = append(msg.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5},
|
||||
A: net.ParseIP("1.2.3.5").To4(),
|
||||
})
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if neg {
|
||||
t.Error("neg = true for a positive answer")
|
||||
}
|
||||
if ttl != 10 {
|
||||
t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clamp down to MaxTTL", func(t *testing.T) {
|
||||
m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil)
|
||||
ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4"))
|
||||
if ttl != 60 {
|
||||
t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
msg.Rcode = dns.RcodeNameError
|
||||
msg.Ns = []dns.RR{soa(45)}
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg {
|
||||
t.Error("neg = false for NXDOMAIN")
|
||||
}
|
||||
if ttl != 45 {
|
||||
t.Errorf("ttl = %d, want 45", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
msg.Ns = []dns.RR{soa(12)}
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg || ttl != 12 {
|
||||
t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) {
|
||||
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("nope.example.com.", dns.TypeA)
|
||||
msg.Response = true
|
||||
ttl, neg := m.TTLFor(msg)
|
||||
if !neg || ttl != 30 {
|
||||
t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated answers are never cached", func(t *testing.T) {
|
||||
msg := answer(t, "big.example.com", 600, "1.2.3.4")
|
||||
msg.Truncated = true
|
||||
if ttl, _ := m.TTLFor(msg); ttl != 0 {
|
||||
t.Errorf("ttl = %d for a truncated answer, want 0", ttl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient rcodes are never cached", func(t *testing.T) {
|
||||
for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} {
|
||||
msg := answer(t, "a.example.com", 600, "1.2.3.4")
|
||||
msg.Rcode = rc
|
||||
if ttl, _ := m.TTLFor(msg); ttl != 0 {
|
||||
t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil message", func(t *testing.T) {
|
||||
if ttl, _ := m.TTLFor(nil); ttl != 0 {
|
||||
t.Errorf("ttl = %d, want 0", ttl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManagerPutResponseGetAndAgeing(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("www.example.com")
|
||||
m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53")
|
||||
|
||||
res := m.Get(k)
|
||||
if res.Kind != Fresh {
|
||||
t.Fatalf("kind = %v, want Fresh", res.Kind)
|
||||
}
|
||||
if res.TTL != 120 {
|
||||
t.Errorf("TTL = %d, want 120", res.TTL)
|
||||
}
|
||||
if res.Upstream != "127.0.0.53:53" {
|
||||
t.Errorf("upstream = %q, want the address it came from", res.Upstream)
|
||||
}
|
||||
if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
|
||||
t.Errorf("answer = %s, want 93.184.216.34", got)
|
||||
}
|
||||
|
||||
// The stored copy is private: mutating the returned message must not
|
||||
// change what the next reader sees.
|
||||
res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4()
|
||||
again := m.Get(k)
|
||||
if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
|
||||
t.Errorf("answer after mutating the first result = %s, want an independent copy", got)
|
||||
}
|
||||
|
||||
// TTLs age down monotonically.
|
||||
clk.Advance(30 * time.Second)
|
||||
if got := m.Get(k).TTL; got != 90 {
|
||||
t.Errorf("TTL after 30s = %d, want 90", got)
|
||||
}
|
||||
if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 {
|
||||
t.Errorf("record TTL after 30s = %d, want 90", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerServeStale(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil)
|
||||
k := key("stale.example.com")
|
||||
m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up")
|
||||
|
||||
clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window
|
||||
res := m.Get(k)
|
||||
if res.Kind != Stale {
|
||||
t.Fatalf("kind = %v, want Stale", res.Kind)
|
||||
}
|
||||
if !res.ServedStale {
|
||||
t.Error("ServedStale = false on a stale hit")
|
||||
}
|
||||
if res.TTL != 0 {
|
||||
t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL)
|
||||
}
|
||||
clk.Advance(61 * time.Second) // now past TTL+ServeStale
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v past the stale window, want Miss", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerServeStaleDisabled(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("nostale.example.com")
|
||||
m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up")
|
||||
clk.Advance(61 * time.Second)
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
k := key("nocache.example.com")
|
||||
m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up")
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDeleteNameAndSubtree(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
now := clk.Now()
|
||||
names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"}
|
||||
for _, n := range names {
|
||||
m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up")
|
||||
}
|
||||
// "sub.blocked.example.com" must not be caught by the "blocked.example.com"
|
||||
// subtree walk, but "blocked.example.com" must not catch
|
||||
// "notblocked.example.com" either.
|
||||
if n := m.DeleteName("Blocked.Example.Com."); n != 1 {
|
||||
t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n)
|
||||
}
|
||||
if res := m.Get(key("blocked.example.com")); res.Kind != Miss {
|
||||
t.Error("deleted name still served")
|
||||
}
|
||||
if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 {
|
||||
t.Errorf("DeleteSubtree = %d, want 1", n)
|
||||
}
|
||||
for _, n := range []string{"keep.example.com", "notblocked.example.com"} {
|
||||
if res := m.Get(key(n)); res.Kind != Fresh {
|
||||
t.Errorf("%s was dropped by an unrelated delete", n)
|
||||
}
|
||||
}
|
||||
_ = now
|
||||
}
|
||||
|
||||
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
for range 5 {
|
||||
m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up")
|
||||
}
|
||||
for range 2 {
|
||||
m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up")
|
||||
}
|
||||
top := m.TopEntries(10)
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("TopEntries returned %d rows, want at least 2", len(top))
|
||||
}
|
||||
// Each Put counts too, so a name queried once still shows up.
|
||||
var hot, warm uint64
|
||||
for _, e := range top {
|
||||
switch e.Name {
|
||||
case "hot.example.com":
|
||||
hot = e.Hits
|
||||
case "warm.example.com":
|
||||
warm = e.Hits
|
||||
}
|
||||
}
|
||||
if hot < 5 || warm < 2 {
|
||||
t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm)
|
||||
}
|
||||
if top[0].Name != "hot.example.com" {
|
||||
t.Errorf("first row = %s, want the hottest name first", top[0].Name)
|
||||
}
|
||||
m.Flush()
|
||||
if got := len(m.TopEntries(10)); got != 0 {
|
||||
t.Errorf("TopEntries after Flush = %d rows, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStatsShape(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil)
|
||||
m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up")
|
||||
m.Get(key("a.example.com"))
|
||||
m.Get(key("miss.example.com"))
|
||||
s := m.Stats()
|
||||
if !s.Enabled {
|
||||
t.Error("Enabled = false on a live manager")
|
||||
}
|
||||
if s.Entries != 1 {
|
||||
t.Errorf("Entries = %d, want 1", s.Entries)
|
||||
}
|
||||
if s.Hits != 1 || s.Misses != 1 {
|
||||
t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses)
|
||||
}
|
||||
if s.Size != 100 || s.Shards != 4 {
|
||||
t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards)
|
||||
}
|
||||
if s.MemoryHuman == "" || s.Bytes <= 0 {
|
||||
t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman)
|
||||
}
|
||||
if s.Persist.Enabled {
|
||||
t.Error("Persist.Enabled = true without a store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KiB"},
|
||||
{1536, "1.5 KiB"},
|
||||
{1024 * 1024, "1.0 MiB"},
|
||||
{3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"},
|
||||
{-1024, "-1.0 KiB"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := HumanBytes(tc.in); got != tc.want {
|
||||
t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSnapshotAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 2)
|
||||
for i := range 25 {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, err := answer(t, n, 600, "10.0.0.8").Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("pack: %v", err)
|
||||
}
|
||||
l.Put(key(n), &Entry{
|
||||
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53",
|
||||
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
|
||||
})
|
||||
}
|
||||
written, err := st.Snapshot(l, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if written != 25 {
|
||||
t.Errorf("Snapshot wrote %d entries, want 25", written)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("snapshot file missing or empty: %v", err)
|
||||
}
|
||||
|
||||
// A fresh LRU restored from the file keeps the answers and their metadata.
|
||||
restored := NewLRU(100, 2)
|
||||
loaded, skipped, err := st.Load(restored, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 25 || skipped != 0 {
|
||||
t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped)
|
||||
}
|
||||
e, kind := restored.Get(key("n7.example.com"), now)
|
||||
if e == nil || kind != Fresh {
|
||||
t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind)
|
||||
}
|
||||
if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" {
|
||||
t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream)
|
||||
}
|
||||
// The wire is intact.
|
||||
msg := new(dns.Msg)
|
||||
if err := msg.Unpack(e.Wire); err != nil {
|
||||
t.Fatalf("unpack restored wire: %v", err)
|
||||
}
|
||||
if len(msg.Answer) != 1 {
|
||||
t.Errorf("restored answer has %d records, want 1", len(msg.Answer))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoadSkipsExpiredEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 1)
|
||||
for i, ttl := range []uint32{600, 600, 5} {
|
||||
n := "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, _ := answer(t, n, ttl, "10.0.0.9").Pack()
|
||||
l.Put(key(n), &Entry{
|
||||
Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
|
||||
StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire),
|
||||
})
|
||||
}
|
||||
if _, err := st.Snapshot(l, now); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
// Load 10s later: the 5s entry is dead, the two 600s entries are fresh.
|
||||
restored := NewLRU(100, 1)
|
||||
loaded, skipped, err := st.Load(restored, now.Add(10*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 2 {
|
||||
t.Errorf("loaded = %d, want 2", loaded)
|
||||
}
|
||||
if skipped != 1 {
|
||||
t.Errorf("skipped = %d, want 1", skipped)
|
||||
}
|
||||
if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss {
|
||||
t.Errorf("expired entry came back after a reload (kind = %v)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTruncateAndStats(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.db")
|
||||
st, err := OpenStore(StoreOptions{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
clk := newClock()
|
||||
now := clk.Now()
|
||||
l := NewLRU(100, 1)
|
||||
wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack()
|
||||
l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
|
||||
if _, err := st.Snapshot(l, now); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
s := st.Stats()
|
||||
if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 {
|
||||
t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s)
|
||||
}
|
||||
if !s.LastSnapshot.IsZero() {
|
||||
t.Logf("LastSnapshot = %v", s.LastSnapshot)
|
||||
}
|
||||
if err := st.Truncate(); err != nil {
|
||||
t.Fatalf("Truncate: %v", err)
|
||||
}
|
||||
if s := st.Stats(); s.Entries != 0 {
|
||||
t.Errorf("entries after Truncate = %d, want 0", s.Entries)
|
||||
}
|
||||
restored := NewLRU(100, 1)
|
||||
loaded, _, err := st.Load(restored, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Load after Truncate: %v", err)
|
||||
}
|
||||
if loaded != 0 {
|
||||
t.Errorf("loaded = %d after Truncate, want 0", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerFlushDropsSnapshot checks the interaction the blocklist API
|
||||
// depends on: flushing the cache must also clear the persisted snapshot, or a
|
||||
// restart would resurrect answers an operator just invalidated.
|
||||
func TestManagerFlushDropsSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st)
|
||||
k := key("flushed.example.com")
|
||||
m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up")
|
||||
if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
m.Flush()
|
||||
restored := NewLRU(10, 1)
|
||||
loaded, _, err := st.Load(restored, clk.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded != 0 {
|
||||
t.Errorf("loaded = %d entries after Flush, want 0", loaded)
|
||||
}
|
||||
if res := m.Get(k); res.Kind != Miss {
|
||||
t.Errorf("kind = %v after Flush, want Miss", res.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUStatsHitRate(t *testing.T) {
|
||||
l := NewLRU(100, 1)
|
||||
now := newClock().Now()
|
||||
l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4})
|
||||
for range 3 {
|
||||
l.Get(key("a.example.com"), now)
|
||||
}
|
||||
for range 1 {
|
||||
l.Get(key("nothere.example.com"), now)
|
||||
}
|
||||
s := l.Stats()
|
||||
if s.Hits != 3 || s.Misses != 1 {
|
||||
t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses)
|
||||
}
|
||||
if s.HitRate < 0.74 || s.HitRate > 0.76 {
|
||||
t.Errorf("HitRate = %v, want ~0.75", s.HitRate)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLRUGet(b *testing.B) {
|
||||
l := NewLRU(1<<16, 16)
|
||||
now := time.Now()
|
||||
names := make([]string, 4096)
|
||||
for i := range names {
|
||||
names[i] = "n" + strconv.Itoa(i) + ".example.com"
|
||||
wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack()
|
||||
l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := rand.Int()
|
||||
for pb.Next() {
|
||||
l.Get(key(names[i%len(names)]), now)
|
||||
i += 7
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkLRUPut(b *testing.B) {
|
||||
l := NewLRU(1<<16, 16)
|
||||
now := time.Now()
|
||||
b.ResetTimer()
|
||||
for i := 0; b.Loop(); i++ {
|
||||
l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkManagerGetUnpack(b *testing.B) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
names := make([]string, 4096)
|
||||
for i := range names {
|
||||
names[i] = "n" + strconv.Itoa(i) + ".example.com"
|
||||
m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up")
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := rand.Int()
|
||||
for pb.Next() {
|
||||
m.Get(key(names[i%len(names)]))
|
||||
i += 7
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+10
-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,5 @@ func (l *LRU) SetCapacity(size, shards int) {
|
||||
shards = len(l.shards)
|
||||
}
|
||||
shards = roundPow2(shards)
|
||||
l.perShard = max(1, size/shards)
|
||||
l.perShard.Store(int64(max(1, size/shards)))
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
+284
-247
@@ -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 {
|
||||
// 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
|
||||
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
|
||||
// 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)),
|
||||
}
|
||||
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 {
|
||||
// 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 {
|
||||
up.addr = up.cfg.Addr
|
||||
continue
|
||||
return s.Addr, nil
|
||||
}
|
||||
if addrs := p.bootstrap[host]; len(addrs) > 0 {
|
||||
up.resolved = addrs
|
||||
up.addr = net.JoinHostPort(addrs[0], port)
|
||||
continue
|
||||
if addrs := bootstraps[host]; len(addrs) > 0 {
|
||||
return net.JoinHostPort(addrs[0], port), addrs
|
||||
}
|
||||
// Fall back to the system resolver.
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
up.addr = up.cfg.Addr
|
||||
continue
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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{
|
||||
nc = &dns.Client{
|
||||
Net: "tcp-tls",
|
||||
TLSConfig: &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,
|
||||
}
|
||||
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 {
|
||||
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.Success(rtt2)
|
||||
return resp2, 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
|
||||
}
|
||||
|
||||
// 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,806 @@
|
||||
package zonefile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func mustParse(t *testing.T, src string, opt Options) *Zone {
|
||||
t.Helper()
|
||||
z, err := Parse(strings.NewReader(src), opt)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: unexpected error: %v\nsource:\n%s", err, src)
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
func parseErr(t *testing.T, src string, opt Options) error {
|
||||
t.Helper()
|
||||
z, err := Parse(strings.NewReader(src), opt)
|
||||
if err == nil {
|
||||
t.Fatalf("Parse: expected an error, got %d records: %v", len(z.Records), z.Records)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// summary renders every record as "line name ttl class type rdata" so a single
|
||||
// comparison covers folding, owner/TTL/class inheritance and origin resolution.
|
||||
func summary(t *testing.T, z *Zone) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
||||
if len(parts) != 5 {
|
||||
t.Fatalf("record line %d does not render as 5 tab-separated fields: %q", rec.Line, rec.Text())
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d %s %d %s %s %s",
|
||||
rec.Line, parts[0], rec.TTL, parts[2], parts[3], parts[4]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func checkSummary(t *testing.T, z *Zone, want []string) {
|
||||
t.Helper()
|
||||
got := summary(t, z)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want uint32
|
||||
wantErr bool
|
||||
}{
|
||||
{in: "", wantErr: true},
|
||||
{in: " ", wantErr: true},
|
||||
{in: "300", want: 300},
|
||||
{in: " 60 ", want: 60},
|
||||
{in: "0", want: 0},
|
||||
{in: "300s", want: 300},
|
||||
{in: "300sec", want: 300},
|
||||
{in: "300seconds", want: 300},
|
||||
{in: "5m", want: 300},
|
||||
{in: "5min", want: 300},
|
||||
{in: "5minutes", want: 300},
|
||||
{in: "1h", want: 3600},
|
||||
{in: "1hour", want: 3600},
|
||||
{in: "1hours", want: 3600},
|
||||
{in: "1d", want: 86400},
|
||||
{in: "1days", want: 86400},
|
||||
{in: "1w", want: 604800},
|
||||
{in: "1weeks", want: 604800},
|
||||
{in: "1h30m", want: 5400},
|
||||
{in: "1w2d3h4m5s", want: 788645},
|
||||
{in: "1h30", want: 3630},
|
||||
{in: "4294967295", want: 4294967295},
|
||||
{in: "4294967296", wantErr: true},
|
||||
{in: "-1", wantErr: true},
|
||||
{in: "-5m", wantErr: true},
|
||||
{in: "abc", wantErr: true},
|
||||
{in: "m", wantErr: true},
|
||||
{in: "1x", wantErr: true},
|
||||
{in: "1.5h", wantErr: true},
|
||||
{in: "1h-", wantErr: true},
|
||||
{in: "0x10", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := ParseTTL(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseTTL(%q) = %d, want an error", tc.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseTTL(%q) error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseTTL(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
in uint32
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{59, "59"},
|
||||
{60, "1m"},
|
||||
{300, "5m"},
|
||||
{3600, "1h"},
|
||||
{86400, "1d"},
|
||||
{604800, "1w"},
|
||||
{3601, "3601"},
|
||||
{1209600, "2w"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := FormatTTL(tc.in); got != tc.want {
|
||||
t.Errorf("FormatTTL(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTTLRoundTrip(t *testing.T) {
|
||||
for _, ttl := range []uint32{0, 1, 59, 60, 300, 3600, 3601, 86400, 604800, 1209600, 4294967295} {
|
||||
s := FormatTTL(ttl)
|
||||
got, err := ParseTTL(s)
|
||||
if err != nil {
|
||||
t.Errorf("ParseTTL(FormatTTL(%d)=%q) error: %v", ttl, s, err)
|
||||
continue
|
||||
}
|
||||
if got != ttl {
|
||||
t.Errorf("round trip of %d via %q = %d", ttl, s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerialNext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current uint32
|
||||
spec string
|
||||
want uint32
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "increment", current: 1, spec: "+1", want: 2},
|
||||
{name: "increment large", current: 2024010101, spec: "+100", want: 2024010201},
|
||||
{name: "increment from zero", current: 0, spec: "+7", want: 7},
|
||||
{name: "decrement", current: 10, spec: "-1", want: 9},
|
||||
{name: "decrement below zero wraps", current: 0, spec: "-1", want: 4294967295},
|
||||
{name: "absolute", current: 5, spec: "42", want: 42},
|
||||
{name: "absolute max", current: 5, spec: "4294967295", want: 4294967295},
|
||||
{name: "surrounding space", current: 1, spec: " +1 ", want: 2},
|
||||
{name: "empty", current: 1, spec: "", wantErr: true},
|
||||
{name: "only sign", current: 1, spec: "+", wantErr: true},
|
||||
{name: "not a number", current: 1, spec: "+x", wantErr: true},
|
||||
{name: "word", current: 1, spec: "next", wantErr: true},
|
||||
{name: "bare minus", current: 1, spec: "-", wantErr: true},
|
||||
{name: "overflow", current: 1, spec: "+4294967296", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := SerialNext(tc.current, tc.spec)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("SerialNext(%d, %q) = %d, want an error", tc.current, tc.spec, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("SerialNext(%d, %q): %v", tc.current, tc.spec, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("SerialNext(%d, %q) = %d, want %d", tc.current, tc.spec, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// addSerial is plain modular (RFC 1982 §2.1) arithmetic: 0xFFFFFFFF + 1 is 0,
|
||||
// not 1. The high-half special case only keeps two large operands from being
|
||||
// mistaken for a wrap.
|
||||
func TestAddSerial(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b, want uint32
|
||||
}{
|
||||
{1, 1, 2},
|
||||
{0, 0, 0},
|
||||
{4294967295, 1, 0},
|
||||
{4294967295, 2, 1},
|
||||
{4294967294, 3, 1},
|
||||
{0, 4294967295, 4294967295},
|
||||
{0x7FFFFFFF, 1, 0x80000000},
|
||||
{0x80000000, 0x80000000, 0},
|
||||
{0x80000000, 0x7FFFFFFF, 0xFFFFFFFF},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := addSerial(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("addSerial(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullZone = `$ORIGIN example.com.
|
||||
$TTL 1h
|
||||
@ IN SOA ns1.example.com. hostmaster.example.com. (
|
||||
2024010101 ; serial
|
||||
7200 ; refresh
|
||||
3600 ; retry
|
||||
1209600 ; expire
|
||||
300 ) ; minimum
|
||||
@ IN NS ns1.example.com.
|
||||
www IN A 192.0.2.1
|
||||
www 600 IN A 192.0.2.2
|
||||
AAAA 2001:db8::1
|
||||
alias IN CNAME www.example.com.
|
||||
mail IN MX 10 mx1.example.com.
|
||||
note IN TXT "hello ; world @ example"
|
||||
*.wild IN A 192.0.2.3
|
||||
198.51.100.5. IN A 198.51.100.5
|
||||
low in a 192.0.2.7
|
||||
mixed 600 IN A 192.0.2.8
|
||||
cls in 3600 A 192.0.2.9
|
||||
at IN TXT "@"
|
||||
|
||||
; a whole-line comment
|
||||
; an indented comment
|
||||
|
||||
`
|
||||
|
||||
func TestParseFullZone(t *testing.T) {
|
||||
z := mustParse(t, fullZone, Options{File: "full.zone", Origin: "ignored.example."})
|
||||
if z.Origin != "example.com." {
|
||||
t.Errorf("Origin = %q, want %q", z.Origin, "example.com.")
|
||||
}
|
||||
if z.File != "full.zone" {
|
||||
t.Errorf("File = %q, want %q", z.File, "full.zone")
|
||||
}
|
||||
checkSummary(t, z, []string{
|
||||
`3 example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 2024010101 7200 3600 1209600 300`,
|
||||
`9 example.com. 3600 IN NS ns1.example.com.`,
|
||||
`10 www.example.com. 3600 IN A 192.0.2.1`,
|
||||
`11 www.example.com. 600 IN A 192.0.2.2`,
|
||||
`12 www.example.com. 3600 IN AAAA 2001:db8::1`,
|
||||
`13 alias.example.com. 3600 IN CNAME www.example.com.`,
|
||||
`14 mail.example.com. 3600 IN MX 10 mx1.example.com.`,
|
||||
`15 note.example.com. 3600 IN TXT "hello ; world @ example"`,
|
||||
`16 *.wild.example.com. 3600 IN A 192.0.2.3`,
|
||||
`17 198.51.100.5. 3600 IN A 198.51.100.5`,
|
||||
`18 low.example.com. 3600 IN A 192.0.2.7`,
|
||||
`19 mixed.example.com. 600 IN A 192.0.2.8`,
|
||||
`20 cls.example.com. 3600 IN A 192.0.2.9`,
|
||||
`21 at.example.com. 3600 IN TXT "@"`,
|
||||
})
|
||||
if z.SOA == nil {
|
||||
t.Fatal("SOA = nil, want the parsed start of authority")
|
||||
}
|
||||
if z.SOA.Serial != 2024010101 {
|
||||
t.Errorf("SOA.Serial = %d, want 2024010101", z.SOA.Serial)
|
||||
}
|
||||
if z.DefaultTTL != 3600 {
|
||||
t.Errorf("DefaultTTL = %d, want 3600 ($TTL 1h)", z.DefaultTTL)
|
||||
}
|
||||
if z.MinTTL != 600 {
|
||||
t.Errorf("MinTTL = %d, want 600 (the smallest explicit TTL)", z.MinTTL)
|
||||
}
|
||||
for _, rec := range z.Records {
|
||||
if rec.RR.Header().Class != dns.ClassINET {
|
||||
t.Errorf("record %q has class %s, want IN", rec.Text(), dns.ClassToString[rec.RR.Header().Class])
|
||||
}
|
||||
if rec.Name != strings.ToLower(rec.Name) {
|
||||
t.Errorf("record name %q is not lowercased", rec.Name)
|
||||
}
|
||||
if !strings.HasSuffix(rec.Name, ".") {
|
||||
t.Errorf("record name %q is not fully qualified", rec.Name)
|
||||
}
|
||||
if rec.File != "full.zone" {
|
||||
t.Errorf("record %q has File %q, want full.zone", rec.Text(), rec.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTTLInheritance(t *testing.T) {
|
||||
z := mustParse(t, `$ORIGIN example.com.
|
||||
$TTL 1h
|
||||
one IN A 192.0.2.1
|
||||
two 300 IN A 192.0.2.2
|
||||
three IN A 192.0.2.3
|
||||
$TTL 2h
|
||||
four IN A 192.0.2.4
|
||||
`, Options{})
|
||||
checkSummary(t, z, []string{
|
||||
`2 one.example.com. 3600 IN A 192.0.2.1`,
|
||||
`3 two.example.com. 300 IN A 192.0.2.2`,
|
||||
`4 three.example.com. 3600 IN A 192.0.2.3`,
|
||||
`6 four.example.com. 7200 IN A 192.0.2.4`,
|
||||
})
|
||||
if z.DefaultTTL != 7200 {
|
||||
t.Errorf("DefaultTTL = %d, want 7200", z.DefaultTTL)
|
||||
}
|
||||
if z.MinTTL != 300 {
|
||||
t.Errorf("MinTTL = %d, want 300", z.MinTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultTTLSources(t *testing.T) {
|
||||
t.Run("option default ttl", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com.", DefaultTTL: 42})
|
||||
if len(z.Records) != 1 || z.Records[0].TTL != 42 {
|
||||
t.Fatalf("records = %v, want one record with TTL 42", summary(t, z))
|
||||
}
|
||||
if z.DefaultTTL != 42 {
|
||||
t.Errorf("DefaultTTL = %d, want 42", z.DefaultTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no ttl source leaves zero", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if len(z.Records) != 1 || z.Records[0].TTL != 0 {
|
||||
t.Fatalf("records = %v, want one record with TTL 0", summary(t, z))
|
||||
}
|
||||
if z.MinTTL != 0 {
|
||||
t.Errorf("MinTTL = %d, want 0", z.MinTTL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soa ttl is the default", func(t *testing.T) {
|
||||
z := mustParse(t, "@ 300 IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5\nhost IN A 192.0.2.1\n",
|
||||
Options{Origin: "example.com."})
|
||||
if z.DefaultTTL != 300 {
|
||||
t.Errorf("DefaultTTL = %d, want 300 from the SOA", z.DefaultTTL)
|
||||
}
|
||||
if z.Records[1].TTL != 300 {
|
||||
t.Errorf("records = %v, want 300 inherited from the SOA", summary(t, z))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soa minttl is the last resort", func(t *testing.T) {
|
||||
z := mustParse(t, "@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 7\nhost IN A 192.0.2.1\n",
|
||||
Options{Origin: "example.com."})
|
||||
if z.Records[1].TTL != 7 {
|
||||
t.Errorf("records = %v, want the SOA minimum 7", summary(t, z))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOwnerForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "at sign is the origin",
|
||||
src: "@ IN A 192.0.2.1\n@ IN A 192.0.2.2\n",
|
||||
want: []string{"origin.example. 192.0.2.1", "origin.example. 192.0.2.2"},
|
||||
},
|
||||
{
|
||||
name: "at sign follows $ORIGIN",
|
||||
src: "$ORIGIN sub.example.com.\n@ IN A 192.0.2.1\n",
|
||||
want: []string{"sub.example.com. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "omitted owner repeats the previous one",
|
||||
src: "one IN A 192.0.2.1\n IN A 192.0.2.2\n IN AAAA 2001:db8::1\n A 192.0.2.3\n",
|
||||
want: []string{
|
||||
"one.origin.example. 192.0.2.1",
|
||||
"one.origin.example. 192.0.2.2",
|
||||
"one.origin.example. 2001:db8::1",
|
||||
"one.origin.example. 192.0.2.3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "omitted owner after a parenthesised record",
|
||||
src: "one IN TXT ( \"one\" ; comment\n \"two\" )\n IN A 192.0.2.1\n",
|
||||
want: []string{"one.origin.example. \"one\" \"two\"", "one.origin.example. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "wildcard owner is expanded but keeps the star",
|
||||
src: "*.sub IN A 192.0.2.1\n",
|
||||
want: []string{"*.sub.origin.example. 192.0.2.1"},
|
||||
},
|
||||
{
|
||||
name: "uppercase owner is lowercased",
|
||||
src: "WWW IN A 192.0.2.1\n",
|
||||
want: []string{"www.origin.example. 192.0.2.1"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
z := mustParse(t, tc.src, Options{Origin: "origin.example."})
|
||||
got := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
||||
got = append(got, parts[0]+" "+parts[4])
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
// Record.Name is the resolved, lowercased, fully qualified owner:
|
||||
// "@" always resolves to the origin, never to a literal "@" label.
|
||||
for _, rec := range z.Records {
|
||||
if strings.ContainsAny(rec.Name, "@") {
|
||||
t.Errorf("Record.Name = %q, want an expanded owner", rec.Name)
|
||||
}
|
||||
if !strings.HasSuffix(rec.Name, ".") {
|
||||
t.Errorf("Record.Name = %q, want a fully qualified name", rec.Name)
|
||||
}
|
||||
if rec.Name != strings.ToLower(rec.Name) {
|
||||
t.Errorf("Record.Name = %q, want lowercase", rec.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParenthesised(t *testing.T) {
|
||||
t.Run("multi line rdata", func(t *testing.T) {
|
||||
z := mustParse(t, `@ SOA ns1.example.com. hostmaster.example.com. (
|
||||
2024010101
|
||||
7200
|
||||
3600
|
||||
1209600
|
||||
300 )
|
||||
`, Options{Origin: "example.com."})
|
||||
if z.SOA == nil {
|
||||
t.Fatal("SOA = nil")
|
||||
}
|
||||
if z.SOA.Minttl != 300 {
|
||||
t.Errorf("SOA.Minttl = %d, want 300", z.SOA.Minttl)
|
||||
}
|
||||
if z.Records[0].Line != 1 {
|
||||
t.Errorf("Line = %d, want 1 (the start of the folded record)", z.Records[0].Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comment inside parentheses", func(t *testing.T) {
|
||||
z := mustParse(t, "host TXT ( \"one\" ; first\n \"two\" ; second\n )\n", Options{Origin: "example.com."})
|
||||
if len(z.Records) != 1 {
|
||||
t.Fatalf("got %d records, want 1", len(z.Records))
|
||||
}
|
||||
if got := summary(t, z)[0]; !strings.HasSuffix(got, `IN TXT "one" "two"`) {
|
||||
t.Errorf("record = %q, want a two-string TXT", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rdata on one line with parens", func(t *testing.T) {
|
||||
z := mustParse(t, "host MX ( 10 mx1.example.com. 20 mx2.example.com. )\n", Options{Origin: "example.com."})
|
||||
if len(z.Records) != 1 {
|
||||
t.Fatalf("got %d records, want 1", len(z.Records))
|
||||
}
|
||||
if got := summary(t, z)[0]; !strings.HasSuffix(got, "IN MX 10 mx1.example.com. 20 mx2.example.com.") {
|
||||
t.Errorf("record = %q, want two MX rdata fields", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOriginDirective(t *testing.T) {
|
||||
t.Run("relative names follow $ORIGIN", func(t *testing.T) {
|
||||
z := mustParse(t, "$ORIGIN sub.example.com.\nwww IN A 192.0.2.1\n", Options{Origin: "example.com."})
|
||||
if z.Origin != "sub.example.com." {
|
||||
t.Errorf("Origin = %q, want sub.example.com.", z.Origin)
|
||||
}
|
||||
if z.Records[0].Name != "www.sub.example.com." {
|
||||
t.Errorf("name = %q, want www.sub.example.com.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absolute names are left alone", func(t *testing.T) {
|
||||
z := mustParse(t, "$ORIGIN sub.example.com.\nwww.other.example. IN A 192.0.2.1\n", Options{})
|
||||
if z.Records[0].Name != "www.other.example." {
|
||||
t.Errorf("name = %q, want www.other.example.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no origin at all is the root", func(t *testing.T) {
|
||||
z := mustParse(t, "host IN A 192.0.2.1\n", Options{})
|
||||
if z.Origin != "." {
|
||||
t.Errorf("Origin = %q, want .", z.Origin)
|
||||
}
|
||||
if z.Records[0].Name != "host." {
|
||||
t.Errorf("name = %q, want host.", z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("$ORIGIN without a name is an error", func(t *testing.T) {
|
||||
// BIND resets to the zone origin when $ORIGIN carries no argument;
|
||||
// this parser rejects it instead. Documented as current behaviour.
|
||||
err := parseErr(t, "$ORIGIN example.com.\n$ORIGIN\n", Options{})
|
||||
if !strings.Contains(err.Error(), "$ORIGIN needs a name") {
|
||||
t.Errorf("error = %q, want it to mention \"$ORIGIN needs a name\"", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("origin option applies when the file has no $ORIGIN", func(t *testing.T) {
|
||||
z := mustParse(t, "www IN A 192.0.2.1\n", Options{Origin: "example.com"})
|
||||
if z.Origin != "example.com." || z.Records[0].Name != "www.example.com." {
|
||||
t.Errorf("origin = %q, name = %q", z.Origin, z.Records[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseClassHandling(t *testing.T) {
|
||||
z := mustParse(t, `one IN A 192.0.2.1
|
||||
two in A 192.0.2.2
|
||||
three A 192.0.2.3
|
||||
four 300 in A 192.0.2.4
|
||||
five ch A 192.0.2.5
|
||||
six hs A 192.0.2.6
|
||||
seven 300 A 192.0.2.7
|
||||
`, Options{Origin: "example.com."})
|
||||
if len(z.Records) != 7 {
|
||||
t.Fatalf("got %d records, want 7", len(z.Records))
|
||||
}
|
||||
want := []uint16{
|
||||
dns.ClassINET, dns.ClassINET, dns.ClassINET, dns.ClassINET,
|
||||
dns.ClassCHAOS, dns.ClassHESIOD, dns.ClassINET,
|
||||
}
|
||||
for i, rec := range z.Records {
|
||||
if rec.RR.Header().Class != want[i] {
|
||||
t.Errorf("%q has class %s, want %s", rec.Text(),
|
||||
dns.ClassToString[rec.RR.Header().Class], dns.ClassToString[want[i]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentsAndBlankLines(t *testing.T) {
|
||||
z := mustParse(t, `; leading comment
|
||||
$TTL 1h
|
||||
|
||||
; indented comment
|
||||
|
||||
one IN A 192.0.2.1 ; trailing comment
|
||||
|
||||
|
||||
two IN A 192.0.2.2 ;comment without space
|
||||
three IN TXT "semi ; colon" ; real comment
|
||||
`, Options{Origin: "example.com."})
|
||||
if len(z.Records) != 3 {
|
||||
t.Fatalf("got %d records, want 3: %v", len(z.Records), summary(t, z))
|
||||
}
|
||||
if !strings.Contains(summary(t, z)[2], `"semi ; colon"`) {
|
||||
t.Errorf("record = %q, want the quoted semicolon preserved", summary(t, z)[2])
|
||||
}
|
||||
for _, rec := range z.Records {
|
||||
if rec.RR.Header().Ttl != 3600 {
|
||||
t.Errorf("%q TTL = %d, want 3600", rec.Text(), rec.RR.Header().Ttl)
|
||||
}
|
||||
}
|
||||
checkSummary(t, z, []string{
|
||||
`6 one.example.com. 3600 IN A 192.0.2.1`,
|
||||
`9 two.example.com. 3600 IN A 192.0.2.2`,
|
||||
`10 three.example.com. 3600 IN TXT "semi ; colon"`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
opt Options
|
||||
wantSub string
|
||||
}{
|
||||
{name: "no type", src: "host 3600\n", wantSub: "has no type"},
|
||||
{name: "bad token before type", src: "host BOGUS 1.2.3.4\n", wantSub: `unexpected token "BOGUS"`},
|
||||
{name: "bad rdata", src: "host 3600 IN A not-an-ip\n", wantSub: ""},
|
||||
{name: "unterminated parenthesis", src: "host 3600 IN A ( 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
||||
{name: "unterminated parenthesis with a blank line", src: "host 3600 IN A (\n\n 192.0.2.1\n", wantSub: "unterminated parenthesis"},
|
||||
{name: "unterminated quote", src: "host 3600 IN TXT \"unterminated\n", wantSub: ""},
|
||||
{name: "no owner and no previous owner", src: "A 192.0.2.1\n", wantSub: "no owner name"},
|
||||
{name: "bad ttl directive", src: "$TTL forever\n", wantSub: "bad TTL"},
|
||||
{name: "ttl directive without a value", src: "$TTL\n", wantSub: "$TTL needs a value"},
|
||||
{name: "origin directive without a name", src: "$ORIGIN\n", wantSub: "$ORIGIN needs a name"},
|
||||
{name: "include disabled", src: "$INCLUDE other.zone\n", wantSub: "disabled"},
|
||||
{name: "include unsupported", src: "$INCLUDE other.zone\n", opt: Options{MaxIncludeDepth: 3}, wantSub: "not supported"},
|
||||
{name: "include without a name", src: "$INCLUDE\n", wantSub: "$INCLUDE needs a file name"},
|
||||
{name: "error carries file and line", src: "host 3600 IN A 192.0.2.1\nother 3600 IN A nope\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
||||
{name: "directive error carries the line", src: "$TTL 1h\n$TTL\n", opt: Options{File: "bad.zone"}, wantSub: "bad.zone:2:"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := parseErr(t, tc.src, tc.opt)
|
||||
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error = %q, want it to contain %q", err, tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptyFile(t *testing.T) {
|
||||
for _, src := range []string{"", "\n\n", " \n\t\n", "; only a comment\n"} {
|
||||
z := mustParse(t, src, Options{Origin: "example.com."})
|
||||
if len(z.Records) != 0 {
|
||||
t.Errorf("Parse(%q) returned %d records, want 0", src, len(z.Records))
|
||||
}
|
||||
if z.SOA != nil {
|
||||
t.Errorf("Parse(%q) returned an SOA, want nil", src)
|
||||
}
|
||||
if z.Origin != "example.com." {
|
||||
t.Errorf("Parse(%q) origin = %q, want example.com.", src, z.Origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileAndParseFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a.zone")
|
||||
b := filepath.Join(dir, "b.zone")
|
||||
if err := os.WriteFile(a, []byte("$TTL 1h\nwww IN A 192.0.2.1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(b, []byte("$TTL 2h\nmail IN A 192.0.2.2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
z, err := ParseFile(a, "example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if z.File != a || z.Origin != "example.com." || len(z.Records) != 1 {
|
||||
t.Fatalf("ParseFile = %+v", z)
|
||||
}
|
||||
if z.Records[0].Name != "www.example.com." {
|
||||
t.Errorf("name = %q, want www.example.com.", z.Records[0].Name)
|
||||
}
|
||||
if _, err := ParseFile(filepath.Join(dir, "missing.zone"), "example.com."); err == nil {
|
||||
t.Error("ParseFile on a missing file: want an error")
|
||||
}
|
||||
|
||||
merged, err := ParseFiles([]string{a, b}, OriginFromPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFiles: %v", err)
|
||||
}
|
||||
if len(merged.Records) != 2 {
|
||||
t.Fatalf("ParseFiles returned %d records, want 2", len(merged.Records))
|
||||
}
|
||||
if merged.DefaultTTL != 7200 {
|
||||
t.Errorf("merged DefaultTTL = %d, want 7200 (the last file wins)", merged.DefaultTTL)
|
||||
}
|
||||
if _, err := ParseFiles([]string{a, filepath.Join(dir, "missing.zone")}, OriginFromPath); err == nil {
|
||||
t.Error("ParseFiles with a missing file: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginFromPath(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"ads.zone", "ads."},
|
||||
{"rpz/ads.zone", "ads."},
|
||||
{"example.com.zone", "example.com."},
|
||||
{"/var/lib/fancy-dns/example.com.zone", "example.com."},
|
||||
{"plain", "plain."},
|
||||
{".hidden", ".hidden."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := OriginFromPath(tc.in); got != tc.want {
|
||||
t.Errorf("OriginFromPath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDot(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{".", "."},
|
||||
{"example.com", "example.com."},
|
||||
{"example.com.", "example.com."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := ensureDot(tc.in); got != tc.want {
|
||||
t.Errorf("ensureDot(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelative(t *testing.T) {
|
||||
tests := []struct{ name, origin, want string }{
|
||||
{"@", "example.com.", "example.com."},
|
||||
{"www", "example.com.", "www.example.com."},
|
||||
{"www.other.", "example.com.", "www.other."},
|
||||
{"www", ".", "www."},
|
||||
{"*", "example.com.", "*.example.com."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := relative(tc.name, tc.origin); got != tc.want {
|
||||
t.Errorf("relative(%q, %q) = %q, want %q", tc.name, tc.origin, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripComment(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"a ; comment", "a "},
|
||||
{"a;comment", "a"},
|
||||
{`a "has ; semicolon" b`, `a "has ; semicolon" b`},
|
||||
{`a \; escaped`, `a \`},
|
||||
{"no comment here", "no comment here"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := stripComment(tc.in); got != tc.want {
|
||||
t.Errorf("stripComment(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"a b c", []string{"a", "b", "c"}},
|
||||
{`a "b c" d`, []string{"a", `"b c"`, "d"}},
|
||||
{" ", nil},
|
||||
{`a\ b c`, []string{`a\ b`, "c"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := splitFields(tc.in); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("splitFields(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeparen(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"a ( b", "a b"},
|
||||
{"a ) b", "a b"},
|
||||
{"a b", "a b"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := deparen(tc.in); got != tc.want {
|
||||
t.Errorf("deparen(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenClassification(t *testing.T) {
|
||||
for _, s := range []string{"0", "300", "1h30m", "300s"} {
|
||||
if !isTTLToken(s) {
|
||||
t.Errorf("isTTLToken(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "-1", "IN", "in", "CH", "HS", "1x", "forever"} {
|
||||
if isTTLToken(s) {
|
||||
t.Errorf("isTTLToken(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"IN", "in", "In", "CH", "ch", "HS", "hs"} {
|
||||
if !isClassToken(s) {
|
||||
t.Errorf("isClassToken(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "300", "ANY", "cs", "in4"} {
|
||||
if isClassToken(s) {
|
||||
t.Errorf("isClassToken(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"A", "a", "AAAA", "ns", "CNAME", "SOA", "ANY", "SVCB", "HTTPS", "nsec3"} {
|
||||
if !isRRTYPE(s) {
|
||||
t.Errorf("isRRTYPE(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "www", "IN", "300", "BOGUS", "www.example.com."} {
|
||||
if isRRTYPE(s) {
|
||||
t.Errorf("isRRTYPE(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSOA(t *testing.T) {
|
||||
if cloneSOA(nil) != nil {
|
||||
t.Error("cloneSOA(nil) != nil")
|
||||
}
|
||||
in := &dns.SOA{Hdr: dns.RR_Header{Name: "example.com."}, Serial: 7, Minttl: 60}
|
||||
out := cloneSOA(in)
|
||||
if out == in {
|
||||
t.Fatal("cloneSOA returned the same pointer")
|
||||
}
|
||||
if out.Serial != 7 || out.Minttl != 60 {
|
||||
t.Errorf("cloneSOA copied %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereAndDirOf(t *testing.T) {
|
||||
if got := where(""); got != "zone" {
|
||||
t.Errorf("where(\"\") = %q, want zone", got)
|
||||
}
|
||||
if got := where("a.zone"); got != "a.zone" {
|
||||
t.Errorf("where(a.zone) = %q", got)
|
||||
}
|
||||
if got := dirOf("a.zone"); got != "." {
|
||||
t.Errorf("dirOf(a.zone) = %q, want .", got)
|
||||
}
|
||||
if got := dirOf("/x/y/a.zone"); got != "/x/y" {
|
||||
t.Errorf("dirOf(/x/y/a.zone) = %q", got)
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
@@ -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}`
|
||||
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'
|
||||
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>,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+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