initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
{
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
"recent_files": [
"internal/server/stats.go",
"internal/config/config.go"
],
"queries": [
{
"query": "(?i)/api/",
"command": "grep",
"at": "2026-09-27T11:00:15.297072822Z",
"focus_path": "internal/config/config.go"
},
{
"query": "(?i)json:\"(degraded_reason|p99_ms|recent|latency|fields|error)",
"command": "grep",
"at": "2026-09-27T11:00:17.585327659Z",
"focus_path": "internal/server/stats.go"
}
],
"ast_pins": [],
"updated_at": "2026-09-27T11:00:17.585333149Z"
}
+207
View File
@@ -0,0 +1,207 @@
# fancy-dns — development status and continuation plan
Last updated: session checkpoint. Everything below reflects the state of the
working tree at the time of writing, including known-failing tests and the
exact next command to run.
## Goal
A lightweight, high-performance recursive DNS server with:
- **Go backend** — sharded LRU cache backed by bbolt, blocklist replication from
multiple formats, per-client ACLs, query logging, Prometheus metrics.
- **Secure DNS** — DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484).
DNSSEC validation was explicitly scoped **out**; AD/CD/extended-RCODE are
passed through only.
- **React + Tailwind admin console** embedded in the binary.
- **Config as zone files + YAML**, hot-reloadable, with **Valkey/Redis** for
cross-instance sync (chosen over AMQP).
## Architecture decisions (settled with the user)
| Decision | Choice | Why |
|---|---|---|
| Scope | Full production | clustering, metrics, ACLs, query logs, hot reload, Docker, k8s, CI |
| Config store | YAML + DNS master files | reviewable/diffable in git |
| Cluster sync | Valkey (Redis protocol) | simple, mature Go client, easy local dev |
| Secure DNS | DoT + DoH, no DNSSEC validation | avoids signer/anchor machinery; pass-through only |
| Hot cache | Sharded LRU in memory, bbolt for snapshots | no I/O on the hot path; warm restart |
## Package layout
```
cmd/fancy-dns/main.go entrypoint: flags, signals, SIGHUP reload
internal/
config/ Config struct, Duration type, Validate, atomic Store + hooks
zonefile/ RFC 1035 master-file parser ($TTL/$ORIGIN, parens, serial math)
blocklist/ Set (exact/sub/root maps), 4 parsers, Manager (fetch+compile+swap)
cache/ Key, Entry, sharded LRU, TTL policy, serve-stale, bbolt Store
resolver/ Upstream pool: health, circuit breakers, parallel/failover/random
acl/ compiled groups, CIDR/PTR/tag matching, per-group domain policy
localzone/ authoritative data from master files, wildcards, delegations
engine/ THE QUERY PIPELINE (see below), reverse-PTR cache
server/ UDP, TCP, DoT, DoH listeners; all funnel into one handler
qlog/ bbolt query-log ring + live in-memory ring + SSE fanout
metrics/ Prometheus collectors + rolling time-series rings
cluster/ Valkey pub/sub, leader lease, divergence detection
api/ admin REST + SSE, auth, CORS, rate limiting
app/ wiring, lifecycle, API hook implementations
web/ go:embed of the built console
web/ Vite + React + TS + Tailwind console (subagent-built)
deploy/ example config, example zones
```
### Query pipeline order (engine.Handle)
```
validate → ACL deny → per-client concurrency → global concurrency → rate limit
→ group allow/deny domains → local zone → blocklist → cache → upstream
→ cache put → finalize (EDNS0, UDP truncation) → metrics + qlog
```
Cache is a single shard-local lookup; only a miss touches the network.
## Status
### Done and verified
- All packages compile; `go vet ./...` clean.
- `go test ./internal/app/` **passes** (integration suite, ~20 tests) with a fake
upstream on loopback: resolve/caching, all blocklist shapes, allow-over-block,
local zones (exact/wildcard/NODATA/NXDOMAIN), ACL deny + allow_domains +
skip_blocklist, all four block actions, malformed query FORMERR, TCP parity,
API auth (401/403/200), domain add→blocked→delete round trip, ACL update,
upstream update, settings validate/save + rejected-write-leaves-file-alone,
blocklist toggle both directions, dashboard/status shape, logs, cache
endpoints, reload failure keeps serving + success clears error, SSE stream
emits `ready` and `entry`, metrics protected/unprotected, concurrency smoke.
- Verified live end-to-end against the real binary on :15353/:18080 with dig.
- Subagent-verified working examples of `/api/domains/check` for hosts-file,
RPZ-wildcard and inline rules.
### Known failures (fix these first)
1. **`go test -race ./internal/app/` still reports DATA RACE and two failures.**
- `TestDomainAddDeleteRoundTrip` and `TestUpstreamUpdate` fail **only under
`-race`** (they pass without it), which points at timing, not logic.
- I had just started fixing races by moving `engine.sem`, `engine.semSize`
and `engine.upstreamTimeout` behind atomics. The `applyConfig` hook now
writes `e.sem.Store(&sem)`; `Handle` does `sem := e.sem.Load()`. **This
refactor is mid-flight** — re-read `internal/engine/engine.go` before
touching it. Remaining races are almost certainly in
`config.Config` field access from `applyConfig` (e.g. `a.pool.Rebind`
called from the swap hook while queries read `cfg`), and in
`harness_test.go` reading the config file the server rewrites (partially
fixed by snapshotting `doc` before `Start` — verify).
- `internal/app/dbg_test.go` was deleted; do not reintroduce it.
2. **Frontend `web/` is incomplete.** The foundation/pages subagents each ran
out of budget. `src/lib/types.ts`, `format.ts`, `cn.ts`, `vite.config.ts`
exist; `src/index.css` is still the purple Vite template; `src/lib/api.ts`,
`src/hooks/*`, `src/components/ui/*`, `src/components/layout/*` and all of
`src/pages/*` are missing. `npm run build` and `npx tsc -b --noEmit` have
never passed. `internal/web/dist/index.html` is a placeholder so the binary
compiles.
3. **No unit tests outside `internal/app/`.** `blocklist`, `cache`, `zonefile`,
`acl`, `resolver`, `localzone` have no direct tests.
4. **Missing deliverables:** `Makefile`, `.gitignore`, `Dockerfile`, k8s
manifests, CI workflow, `README.md`.
## Continuation plan, in order
### Step 1 — green tests (highest priority)
```bash
go build ./... && go vet ./...
go test -race ./internal/app/ -count=1 2>&1 | grep -A20 "DATA RACE" | head -60
```
Expected races to fix, in likelihood order:
- `config.Store` swap hook running `app.onConfigSwap` → `pool.Rebind` and
`reloadLocalZones` while the query path reads the old `*config.Config`.
Fix direction: make `Pool.Rebind` build a new slice and swap an
`atomic.Pointer` rather than mutating in place under `sync.RWMutex` while
readers hold `RLock` on a *different* field.
- `blocklist.Manager` `m.running` map vs `RefreshAll` goroutine + API handler.
Already mutex-guarded; confirm it is the *Stats* pointer that races.
- `qlog.Logger.live` ring: `Log` writes `l.live[pos%len]` from many goroutines
while `Recent` reads. Needs a seqlock or a per-slot atomic pointer.
- Harness reading the config file path from a test goroutine.
For each race, prefer removing shared mutable state over adding locks: the
design already uses `atomic.Pointer` holders for ACL, blocklist, localzone and
pool; extend that pattern.
### Step 2 — unit tests for the hot packages
Priority order (highest bug-density first):
- `blocklist`: `Set.Match` for `*.x` (subdomains only) vs `**.x` (apex too)
vs exact; allow-over-block priority; hosts/adblock/plain/rpz parser cases;
`DetectFormat` sniffing; duplicate handling.
- `cache`: LRU eviction, TTL clamping, `TTLFor` on NXDOMAIN/NODATA/SERVFAIL,
serve-stale window, key pack/unpack round-trip, bbolt snapshot→load.
- `zonefile`: `$TTL` inheritance, omitted owner/TTL/class, parens, `@`,
`$ORIGIN` relative vs absolute names, `ParseTTL`/`SerialNext`.
- `acl`: first-match-wins, most-specific CIDR, PTR suffix, `DomainSet.Match`.
- `localzone`: delegation cut, closest-wildcard (RFC 1034 §4.3.3), NODATA.
- `resolver`: breaker open/close, parallel race, TCP fallback on truncation.
- Add `BenchmarkLookup` for `Set.Match` and `cache.LRU.Get` to substantiate
the "great performance" claim.
### Step 3 — finish the frontend
Respawn two subagents with disjoint file ownership (this worked structurally;
they just need more budget):
- **Foundation agent** owns `src/index.css` (replace the purple template with
dark-first CSS vars + one emerald accent), `src/lib/api.ts`, `src/hooks/*`,
`src/components/ui/*`, `src/components/layout/*`. The exact prop contract I
gave them is in the transcript and must be kept.
- **Pages agent** owns `src/pages/*` only, coding against the same contract.
Then wire `src/App.tsx` + `src/main.tsx` yourself (react-router, `AppShell`,
`ToastProvider`, theme init) and run `npm run build`. Once `web/dist` is real,
the `//go:embed all:dist` in `internal/web/web.go` picks it up with no Go
change; delete the placeholder `internal/web/dist/index.html`.
API contract for the frontend is fixed and implemented — see
`internal/api/handlers.go` route table and `internal/app/hooks.go` payload
shapes. `/api/logs/stream` is SSE with events `ready`, `entry`, `stats`, and
`X-Accel-Buffering: no`.
### Step 4 — deploy + docs
- `Makefile`: `build`, `test`, `race`, `vet`, `web`, `lint`, `run`, `image`.
- `Dockerfile`: multi-stage — node build for `web/`, then Go build with
`-ldflags "-X ...app.Version=..."`; distroless/scratch; `EXPOSE 53/udp 53/tcp
853/tcp 443/tcp 8080/tcp`; non-root; `VOLUME /var/lib/fancy-dns`.
- k8s: Deployment (hostNetwork or a NodePort Service for :53), Service,
ConfigMap for the YAML, PVC for the bbolt files, readiness probe on
`/api/healthz`, optional Ingress for the console.
- CI: `go vet`, `go test -race`, `gofmt -l`, `npm ci && tsc -b && npm run build`.
- `README.md`: quickstart, config reference, zone-file semantics, API
reference, performance notes, the security caveats that actually matter
(`trusted_proxies` and `X-Forwarded-For`; `metrics.protect`; the
`trust_loopback` + `auth.mode` matrix).
## Design notes worth preserving
- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one
`Unpack` and yields a private copy, so serving never mutates shared state and
TTL ageing is free. Same trade dnsmasq makes.
- **Cache invalidation on rule changes flushes the cache** rather than
guessing which subtrees are affected. A newly blocked name must not keep
serving its cached good answer, and a new allow rule can rescue a name many
lists blocked. `invalidateBlocklistCache()` in `internal/app/hooks.go`.
- **API responses share the field names in `internal/app/hooks.go`.** If you
rename one, grep the frontend too.
- **API keys are compared by SHA-256 hash, constant-time**, and
`X-Forwarded-For` is honoured *only* from `trusted_proxies` — otherwise a
client could spoof its identity past the ACL and the rate limiter.
- **UDP listener resets its read deadline every iteration.** A deadline set
once expires permanently and turns the loop into a spin that never reads
another packet (this was a real bug caught by `TestBlocklistToggle`).
- **Only the Valkey lease holder fetches lists**, so N instances do not hit a
mirror N times. Followers serve their compiled copy.
- **Blocked/serve-stale behaviour:** a served-stale answer gets TTL 1, an
expired upstream answer is never cached, and a truncated answer is never
cached (it would be served truncated forever).
+126
View File
@@ -0,0 +1,126 @@
// Command fancy-dns is a caching, blocklist-aware recursive DNS server with
// DNS-over-TLS, DNS-over-HTTPS and an embedded admin console.
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/app"
)
func main() {
var (
configPath = flag.String("config", envOr("FANCYDNS_CONFIG", "/etc/fancy-dns/config.yaml"), "path to the configuration file")
showVersion = flag.Bool("version", false, "print the version and exit")
checkOnly = flag.Bool("check", false, "validate the configuration and exit")
logLevel = flag.String("log-level", os.Getenv("FANCYDNS_LOG_LEVEL"), "override log level (debug, info, warn, error)")
logFormat = flag.String("log-format", os.Getenv("FANCYDNS_LOG_FORMAT"), "override log format (text, json)")
adminAddr = flag.String("admin-addr", os.Getenv("FANCYDNS_ADMIN_ADDR"), "override the admin API listen address")
)
flag.Parse()
if *showVersion {
fmt.Println("fancy-dns", app.Version)
return
}
if err := run(runOptions{
configPath: *configPath,
logLevel: *logLevel,
logFormat: *logFormat,
adminAddr: *adminAddr,
checkOnly: *checkOnly,
}); err != nil {
fmt.Fprintln(os.Stderr, "fancy-dns:", err)
os.Exit(1)
}
}
type runOptions struct {
configPath string
logLevel string
logFormat string
adminAddr string
checkOnly bool
}
func run(o runOptions) error {
a, err := app.New(app.Options{
ConfigPath: o.configPath,
Version: app.Version,
LogLevel: o.logLevel,
LogFormat: o.logFormat,
})
if err != nil {
return err
}
if o.checkOnly {
fmt.Println("configuration is valid:", o.configPath)
return nil
}
if o.adminAddr != "" {
// Bind the admin API elsewhere without touching the config file, which
// is what a container platform wants.
if err := a.SetAdminAddr(o.adminAddr); err != nil {
return err
}
}
// SIGINT/SIGTERM start a graceful shutdown; SIGHUP reloads the config.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sig := make(chan os.Signal, 4)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(sig)
if err := a.Start(ctx); err != nil {
return err
}
reloadErr := make(chan error, 1)
go func() {
for s := range sig {
switch s {
case syscall.SIGHUP:
a.Log().Info("SIGHUP received, reloading configuration")
if err := a.Reload(); err != nil {
// A failed reload must not kill the server: the previous
// configuration keeps serving and the error shows in /api.
a.Log().Error("reload failed, keeping the running configuration", "error", err)
}
default:
a.Log().Info("signal received, shutting down", "signal", s.String())
cancel()
select {
case reloadErr <- nil:
default:
}
return
}
}
}()
<-ctx.Done()
// Give in-flight queries a moment to finish before tearing listeners down.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := a.Stop(shutdownCtx); err != nil {
return err
}
return <-reloadErr
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+204
View File
@@ -0,0 +1,204 @@
# fancy-dns — example configuration.
#
# Every value below is a default; delete what you do not need. The admin API
# rewrites this file when you save from the console, so keep it in git and
# review the diff.
version: 0
server:
# In-flight upstream resolutions. Excess queries get SERVFAIL rather than
# queueing without bound.
max_concurrent: 1024
timeouts:
udp: 2s
tcp: 4s
tls: 4s
query: 5s
upstream: 2s
list_fetch: 60s
dns:
udp: ["0.0.0.0:53", "[::]:53"]
tcp: ["0.0.0.0:53", "[::]:53"]
# Advertised EDNS0 buffer size for our own answers.
max_udp_size: 1232
serve_stale: 0s
edns0:
# Forward the client's EDNS-Client-Subnet so upstream picks a nearby
# anycast location.
client_subnet: true
ipv4_bits: 24
ipv6_bits: 56
preserve: true
dot:
enabled: false
listen: ["0.0.0.0:853"]
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
min_version: "1.2"
doh:
enabled: false
listen: ["0.0.0.0:443"]
base_path: /dns-query
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
min_version: "1.2"
allow_post: true
transport:
mode: auto
upstream:
# parallel: race them, take the first good answer. random: try N at random.
# failover: walk the list in order.
policy: parallel
max_attempts: 3
healthcheck_interval: 30s
breaker_threshold: 5
breaker_cooldown: 30s
servers:
- addr: 9.9.9.9:53
proto: udp
health_name: dns.quad9.net
- addr: 149.112.112.112:53
proto: udp
health_name: dns.quad9.net
- 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: 200000
shards: 32
min_ttl: 0s
max_ttl: 1h
# Keep expired answers around this long so a dead upstream costs latency,
# not availability.
serve_stale: 30m
prefetch: true
negative_ttl: 30m
persist:
enabled: true
path: data/cache.db
interval: 30s
max_entries: 500000
load_on_start: true
prewarm: []
blocklist:
enabled: true
# nxdomain | zero | redirect | drop | refused
default_action: nxdomain
response_ip: 0.0.0.0
response_ipv6: "::"
response_ttl: 60s
# RPZ-style policy zones and/or local authoritative data, as master files.
local_zones: []
# Inline rules. "*.example.com" covers subdomains, "**.example.com" covers
# the apex too, a bare name matches only itself.
block_entries: []
allow_entries: []
lists:
- name: haas
format: plain
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
path: data/lists/hosts
enabled: true
refresh: 24h
categories: [ads, tracking]
- name: easylist
format: adblock
url: https://easylist.to/easylist/easylist.txt
path: data/lists/easylist.txt
enabled: false
refresh: 24h
categories: [ads]
- name: local-rpz
format: rpz
path: zones/local.rpz.zone
enabled: true
refresh: 0s
acl:
# allow | deny | block_only
default_policy: allow
default_rate_limit: 0
groups:
- name: guests
comment: Blocklists apply, but the kitchen-sink allowlist does not.
match:
cidrs: ["192.168.90.0/24"]
policy: block_only
rate_limit: 50
- name: printers
comment: No recursion at all; they only need DHCP.
match:
cidrs: ["192.168.91.0/24"]
policy: deny
qlog:
enabled: true
path: data/queries.db
max_entries: 200000
flush_interval: 1s
# 1.0 logs every query. Lower it on a busy instance: the per-domain
# counters are what the dashboard actually needs.
sample_rate: 1.0
log_blocked: true
log_cached: false
log_errors: true
live_buffer: 2000
top_domains: 5000
top_ttl: 5m
cluster:
enabled: false
# node_id: dns-1
# advertise: http://10.0.0.10:8080
peer_ttl: 15s
valkey:
addrs: ["valkey:6379"]
prefix: fancydns
pool_size: 10
dial_timeout: 2s
read_timeout: 2s
# Only the lease holder fetches lists, so N instances do not hit a
# mirror N times.
leader_ttl: 30s
api:
enabled: true
listen: ["127.0.0.1:8080"]
# tls_listen: ["0.0.0.0:8443"]
base_path: /api
ui: true
allow_config_write: true
allow_blocklist_write: true
# Only these proxies may set X-Forwarded-For; anything else is ignored, so a
# client cannot spoof its identity past the ACL or the rate limiter.
trusted_proxies: []
cors_origins: []
auth:
# none | apikey | basic
mode: apikey
api_keys: ["CHANGE-ME-long-random-string"]
read_only_api_keys: []
trust_loopback: true
session_ttl: 12h
rate_limit:
per_minute: 600
failures: 10
lockout: 5m
metrics:
enabled: true
path: /metrics
protect: true
log:
level: info
format: text
+25
View File
@@ -0,0 +1,25 @@
; Local authoritative data. fancy-dns answers for these names itself, which
; removes the need for a second nameserver for a handful of records.
$TTL 300
@ SOA ns1.lan. hostmaster.lan. 2024010101 3600 600 604800 300
@ NS ns1.lan.
@ NS ns2.lan.
ns1 A 192.168.1.2
ns2 A 192.168.1.3
router A 192.168.1.1
nas A 192.168.1.10
nas AAAA fd00::10
; A wildcard covers one label: printer.lan., host.lan., ...
*.lan A 192.168.1.50
; A wildcard deeper down.
*.internal.lan A 10.0.0.50
; Services.
_ntp._udp.lan. SRV 0 0 123 ns1.lan.
_ldap._tcp.lan. SRV 10 0 389 nas.lan.
_dmarc._domain.lan. TXT "v=DMARC1; p=reject; rua=mailto:dmarc.lan"
_lan. TXT "internal network, do not use on the open internet"
+23
View File
@@ -0,0 +1,23 @@
; Example RPZ-style policy zone.
;
; Drop everything from these services, for this host and its subdomains.
$TTL 60
@ SOA localhost. hostmaster.localhost. 1 3600 600 604800 60
@ NS localhost.
; *.doubleclick.net CNAME . -> NXDOMAIN
*.doubleclick.net CNAME .
*.ads.example.com CNAME .
*.tracker.example.net CNAME .
analytics.example.org CNAME .
; CNAME rpz-drop. -> answer with silence
*.noisy-cdn.example CNAME rpz-drop.
; CNAME rpz-passthru. -> ignore blocklists for this name
metrics.internal. CNAME rpz-passthru.
; A/AAAA records in a policy zone are returned verbatim, which is the cleanest
; way to point a blocked name at a local landing page.
blocked.example A 192.0.2.10
AAAA 2001:db8::10
+25
View File
@@ -0,0 +1,25 @@
module git.k8s-git-k6n.net/mats/fancy-dns
go 1.27.1
require (
github.com/miekg/dns v1.1.73
github.com/prometheus/client_golang v1.24.1
go.etcd.io/bbolt v1.5.0
golang.org/x/net v0.59.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+422
View File
@@ -0,0 +1,422 @@
// Package acl implements per-client policy: which blocklist rules apply,
// whether the cache may be used, rate limits, and which upstreams to use.
//
// Groups are evaluated in order and the first match wins, so an operator can
// put specific groups above catch-alls. The compiled form is immutable and
// swapped atomically on reload, so lookups need no locks.
package acl
import (
"net/netip"
"sort"
"strings"
"sync/atomic"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Policy is what a matched group (or the default) allows.
type Policy string
const (
// PolicyAllow resolves everything not blocked by the blocklist.
PolicyAllow Policy = "allow"
// PolicyBlockOnly applies the blocklist but refuses nothing.
PolicyBlockOnly Policy = "block_only"
// PolicyDeny answers REFUSED to everything.
PolicyDeny Policy = "deny"
)
// Decision is the outcome of matching a client.
type Decision struct {
// Group is the matched group name, or "" for the default.
Group string
// Policy is the effective policy.
Policy Policy
// SkipBlocklist lets the client bypass blocklist filtering entirely.
SkipBlocklist bool
// SkipCache forces a fresh resolution for this client.
SkipCache bool
// RateLimit is queries per second; 0 means unlimited.
RateLimit int
// MaxConcurrent caps in-flight resolutions for this client; 0 defers to
// the global per-client cap.
MaxConcurrent int
// LogQueries forces query logging regardless of the sampling rate.
LogQueries bool
// Upstreams restricts this client to specific upstream addresses.
Upstreams []string
// AllowDomains and DenyDomains are per-group name overrides, using the
// same wildcard syntax as the blocklist ("*.example.com").
AllowDomains []string
DenyDomains []string
// Default is true when no group matched.
Default bool
}
// Allows reports whether the client may resolve at all.
func (d Decision) Allows() bool { return d.Policy != PolicyDeny }
// DomainSet is a compiled allow/deny name matcher: a set of exact names plus a
// set of parent domains whose subdomains are covered.
type DomainSet struct {
exact map[string]struct{}
subs map[string]struct{} // parent domain, covers it and its subdomains
// any is set when the set contains a rule matching everything.
any bool
}
// NewDomainSet compiles patterns like "*.example.com" (subdomains only),
// "**.example.com" (subdomains plus the apex) and "example.com" (exact).
func NewDomainSet(patterns []string) *DomainSet {
ds := &DomainSet{
exact: make(map[string]struct{}, len(patterns)),
subs: make(map[string]struct{}, len(patterns)),
}
for _, p := range patterns {
p = strings.ToLower(strings.TrimSpace(p))
if p == "" {
continue
}
switch {
case p == "*" || p == "**" || p == "*.*":
ds.any = true
case strings.HasPrefix(p, "**."):
ds.subs[strings.TrimSuffix(p[3:], ".")] = struct{}{}
case strings.HasPrefix(p, "*."):
// A strict-subdomain pattern still needs the parent for the
// suffix walk; store the parent and accept the apex too, since
// an operator writing "*.example.com" almost always means the
// whole domain. Use "example.com" for an exact-only rule.
ds.subs[strings.TrimSuffix(p[2:], ".")] = struct{}{}
default:
ds.exact[strings.TrimSuffix(p, ".")] = struct{}{}
}
}
return ds
}
// Match reports whether name is covered.
func (d *DomainSet) Match(name string) bool {
if d == nil {
return false
}
if d.any {
return true
}
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" {
return false
}
if _, ok := d.exact[name]; ok {
return true
}
if _, ok := d.subs[name]; ok {
return true
}
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
if _, ok := d.subs[name[off:]]; ok {
return true
}
}
return false
}
func labelEnd(s string) int {
if i := strings.IndexByte(s, '.'); i >= 0 {
return i + 1
}
return len(s)
}
// Len reports how many rules the set holds.
func (d *DomainSet) Len() int {
if d == nil {
return 0
}
return len(d.exact) + len(d.subs)
}
// group is one compiled ACL group.
type group struct {
name string
comment string
policy Policy
any bool
prefixes []netip.Prefix
ptr []string // reverse-name suffixes, lowercase with trailing dot
tags []string
allow *DomainSet
deny *DomainSet
skipBlocklist bool
skipCache bool
rateLimit int
maxConcurrent int
logQueries bool
upstreams []string
}
// Engine is the compiled, hot-swappable ACL table.
type Engine struct {
groups []*group
def Decision
cur atomic.Pointer[Engine]
}
// Compile builds an Engine from configuration. An empty group list produces an
// engine that allows everything, which is the safe default for a fresh install.
func Compile(cfg config.ACL) *Engine {
e := &Engine{
def: Decision{
Policy: Policy(cfg.DefaultPolicy),
RateLimit: cfg.DefaultRateLimit,
Default: true,
},
}
if e.def.Policy == "" {
e.def.Policy = PolicyAllow
}
for i := range cfg.Groups {
g := &cfg.Groups[i]
cg := &group{
name: g.Name,
comment: g.Comment,
policy: Policy(g.Policy),
any: g.Match.Any,
ptr: lowerAll(g.Match.PTRSuffixes),
tags: lowerAll(g.Match.Tags),
allow: NewDomainSet(g.AllowDomains),
deny: NewDomainSet(g.DenyDomains),
skipBlocklist: g.SkipBlocklist,
skipCache: g.SkipCache,
rateLimit: g.RateLimit,
maxConcurrent: g.MaxConcurrent,
logQueries: g.LogQueries,
upstreams: append([]string(nil), g.Upstreams...),
}
if cg.policy == "" {
cg.policy = PolicyAllow
}
for _, c := range g.Match.CIDRs {
if p, err := netip.ParsePrefix(c); err == nil {
cg.prefixes = append(cg.prefixes, p)
}
}
// Sort by prefix length descending so the most specific network wins
// when one group lists overlapping ranges (though first-match-wins
// already handles ordering, this keeps a single group honest).
sort.Slice(cg.prefixes, func(i, j int) bool {
return cg.prefixes[i].Bits() > cg.prefixes[j].Bits()
})
e.groups = append(e.groups, cg)
}
return e
}
// Holder keeps the current Engine for the DNS hot path.
type Holder struct{ cur atomic.Pointer[Engine] }
// NewHolder wraps an engine.
func NewHolder(e *Engine) *Holder {
h := &Holder{}
h.cur.Store(e)
return h
}
// Get returns the current engine.
func (h *Holder) Get() *Engine { return h.cur.Load() }
// Set installs a new engine.
func (h *Holder) Set(e *Engine) { h.cur.Store(e) }
// Client is the identity of a DNS client as far as the ACL is concerned.
type Client struct {
// IP is the client address.
IP netip.Addr
// Names are the reverse-DNS names for the client, lowercased. They come
// from a reverse cache the server maintains, so matching is lock-free.
Names []string
// Tags are extra labels derived from the client identity, e.g. "laptop"
// for laptop.lan.internal.
Tags []string
}
// Match resolves a client to a Decision.
func (e *Engine) Match(c Client) Decision {
if e == nil {
return Decision{Policy: PolicyAllow, Default: true}
}
for _, g := range e.groups {
if !g.matches(c) {
continue
}
return Decision{
Group: g.name,
Policy: g.policy,
SkipBlocklist: g.skipBlocklist,
SkipCache: g.skipCache,
RateLimit: g.rateLimit,
MaxConcurrent: g.maxConcurrent,
LogQueries: g.logQueries,
Upstreams: g.upstreams,
AllowDomains: domainPatterns(g.allow),
DenyDomains: domainPatterns(g.deny),
}
}
return e.defaultDecision()
}
// Default returns the decision for a client that matched nothing.
func (e *Engine) Default() Decision { return e.defaultDecision() }
func (e *Engine) defaultDecision() Decision {
d := e.def
d.Default = true
return d
}
func (g *group) matches(c Client) bool {
if g.any {
return true
}
if len(g.prefixes) > 0 && c.IP.IsValid() {
addr := c.IP.Unmap()
for _, p := range g.prefixes {
if p.Contains(addr) {
return true
}
}
}
if len(g.ptr) > 0 {
for _, n := range c.Names {
for _, suffix := range g.ptr {
if strings.HasSuffix(n, suffix) {
return true
}
}
}
}
if len(g.tags) > 0 {
for _, t := range c.Tags {
for _, want := range g.tags {
if t == want {
return true
}
}
}
}
return false
}
// Groups returns the compiled group summaries for the API.
type GroupSummary struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
Policy string `json:"policy"`
CIDRs []string `json:"cidrs"`
PTRSuffixes []string `json:"ptr_suffixes"`
Tags []string `json:"tags"`
Any bool `json:"any"`
AllowDomains []string `json:"allow_domains"`
DenyDomains []string `json:"deny_domains"`
SkipBlocklist bool `json:"skip_blocklist"`
SkipCache bool `json:"skip_cache"`
RateLimit int `json:"rate_limit"`
MaxConcurrent int `json:"max_concurrent"`
LogQueries bool `json:"log_queries"`
Upstreams []string `json:"upstreams"`
ClientCount int64 `json:"client_count"`
QueryCount uint64 `json:"query_count"`
BlockedCount uint64 `json:"blocked_count"`
// Matched is populated by a dry-run lookup from the API.
Matched bool `json:"matched"`
}
// Summaries renders the engine's groups for the API.
func (e *Engine) Summaries() []GroupSummary {
if e == nil {
return nil
}
out := make([]GroupSummary, 0, len(e.groups))
for _, g := range e.groups {
cidrs := make([]string, 0, len(g.prefixes))
for _, p := range g.prefixes {
cidrs = append(cidrs, p.String())
}
out = append(out, GroupSummary{
Name: g.name,
Comment: g.comment,
Policy: string(g.policy),
CIDRs: cidrs,
PTRSuffixes: g.ptr,
Tags: g.tags,
Any: g.any,
AllowDomains: domainPatterns(g.allow),
DenyDomains: domainPatterns(g.deny),
SkipBlocklist: g.skipBlocklist,
SkipCache: g.skipCache,
RateLimit: g.rateLimit,
MaxConcurrent: g.maxConcurrent,
LogQueries: g.logQueries,
Upstreams: g.upstreams,
})
}
return out
}
// Count reports how many groups are configured.
func (e *Engine) Count() int {
if e == nil {
return 0
}
return len(e.groups)
}
func domainPatterns(d *DomainSet) []string {
if d == nil {
return nil
}
out := make([]string, 0, len(d.exact)+len(d.subs))
for k := range d.exact {
out = append(out, k)
}
for k := range d.subs {
out = append(out, "*."+k)
}
sort.Strings(out)
return out
}
func lowerAll(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
continue
}
out = append(out, s)
}
return out
}
// TagsFromName derives candidate tags from a reverse-DNS name, using the
// labels as-is: "laptop.lan.example.net" yields laptop, lan, example, net.
// Matching a group on tag "laptop" then works without any extra
// configuration.
func TagsFromName(name string) []string {
name = strings.ToLower(strings.TrimSuffix(name, "."))
if name == "" {
return nil
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return labels
}
// Skip the last two labels (the name plus its parent domain).
return labels[:len(labels)-2]
}
+623
View File
@@ -0,0 +1,623 @@
// Package api serves the admin REST API, the Prometheus metrics endpoint and
// the embedded web console.
//
// The API is the only component that mutates state, so every handler funnels
// through one of three paths: a read from a live component, a config mutation
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
// (flush cache, refresh lists, reload config).
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Principal is the authenticated identity behind a request.
type Principal struct {
// Mode is "apikey", "basic" or "anonymous".
Mode string
// Name is a human label for the key or user.
Name string
// ReadOnly is true for a read-only API key.
ReadOnly bool
// Loopback is true when the request came from the local machine and
// trust_loopback is on.
Loopback bool
}
// CanWrite reports whether the principal may perform mutations.
func (p Principal) CanWrite() bool { return !p.ReadOnly }
// Hooks are the live components the API reads and drives. They are interfaces
// so the api package does not depend on the engine internals.
type Hooks struct {
// Store is the config store (required).
Store *config.Store
// Logger is the structured logger (required).
Logger *slog.Logger
// Version is the running build's version.
Version string
// StartedAt is the process start time.
StartedAt time.Time
// Dashboard returns the aggregated dashboard payload.
Dashboard func(ctx context.Context, rangeKey string) (any, error)
// BlocklistState returns the compiled blocklist detail.
BlocklistState func() any
// BlocklistStats returns the compiled rule counts.
BlocklistStats func() any
// RefreshBlocklists recompiles the blocklist; empty names means all.
RefreshBlocklists func(ctx context.Context, names []string) error
// ToggleBlocklist enables or disables one source at runtime.
ToggleBlocklist func(name string, enabled bool) error
// DomainCheck explains how a name would be treated.
DomainCheck func(name string) (any, error)
// ListDomains pages the compiled rules.
ListDomains func(q DomainQuery) (any, error)
// AddDomain adds an inline rule and persists it.
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error
// GetACL returns the ACL groups.
GetACL func() any
// PutACL validates and persists new ACL groups.
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
// GetUpstream returns the upstream pool state.
GetUpstream func() any
// PutUpstream validates and persists a new upstream config.
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
// SearchLog pages the query log.
SearchLog func(ctx context.Context, q LogQuery) (any, error)
// LogStats is the live stats event pushed over SSE.
LogStats func() any
// SubscribeLog streams new log entries; the cancel func must be called.
SubscribeLog func() (<-chan LogEntry, func())
// ClearLog empties the query log.
ClearLog func() error
// CacheStats returns the cache snapshot.
CacheStats func() any
// CacheTop returns the hottest cached names.
CacheTop func(limit int) any
// FlushCache empties the cache.
FlushCache func() int64
// Health returns the health payload for /healthz.
Health func() any
// Status returns the per-component runtime status.
Status func() any
// Flush persists buffered state.
Flush func()
}
// DomainQuery pages the compiled rules.
type DomainQuery struct {
Text string
Action string
List string
Page int
PerPage int
}
// DomainInput adds a rule.
type DomainInput struct {
Name string `json:"name"`
Action string `json:"action"`
List string `json:"list"`
Source string `json:"source"`
Content string `json:"content,omitempty"`
}
// LogQuery pages the query log.
type LogQuery struct {
Limit int
Offset int
Text string
Action string
Client string
RCode int
BlockedOnly bool
Since time.Time
}
// LogEntry is the JSON shape of a query-log row, matching what the DNS
// handlers record.
type LogEntry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
RCodeStr string `json:"rcode_name"`
Action string `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
Blocked bool `json:"blocked"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
log *slog.Logger
base string
mux *http.ServeMux
auth *authenticator
trusted []netip.Prefix
// sessions tracks issued session tokens for the basic-auth flow.
sessMu sync.Mutex
sessions map[string]session
// limits tracks per-IP rate limits and lockouts.
limMu sync.Mutex
limits map[string]*limitState
now func() time.Time
}
type session struct {
principal Principal
expires time.Time
}
type limitState struct {
window time.Time
count int
failures int
lockedTill time.Time
}
// New builds the API server.
func New(hooks Hooks) (*Server, error) {
if hooks.Store == nil {
return nil, errors.New("api: Store is required")
}
log := hooks.Logger
if log == nil {
log = slog.Default()
}
cfg := hooks.Store.Get()
s := &Server{
hooks: hooks,
log: log.With("component", "api"),
base: cfg.API.BasePath,
now: time.Now,
sessions: make(map[string]session),
limits: make(map[string]*limitState),
auth: newAuthenticator(cfg.API.Auth),
}
for _, c := range cfg.API.TrustedProxies {
if p, err := netip.ParsePrefix(c); err == nil {
s.trusted = append(s.trusted, p)
}
}
s.mux = s.routes(cfg)
return s, nil
}
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.cors(s.mux))
}
// routes builds the route table. The stdlib mux handles the path prefix, so
// handlers see paths relative to the base.
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux := http.NewServeMux()
// base is the prefix every route is built from, so it must end in a
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
// "/apihealthz".
base := strings.TrimSuffix(s.base, "/") + "/"
// Unauthenticated: health and metrics are for probes, not operators.
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
if cfg.Metrics.Enabled {
// Bound late so the metrics package's init has run by the time the
// handler is invoked; a nil handler means "not wired up" (a test, or
// an embedder that does not import metrics), which must not panic the
// route table.
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h := metricsHandler(); h != nil {
h.ServeHTTP(w, r)
return
}
http.Error(w, "metrics not available", http.StatusNotImplemented)
})))
}
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
// Everything below requires a principal.
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
return mux
}
// recoverer turns a panic in a handler into a 500 instead of a dropped
// connection, and logs it.
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// cors adds the configured origins. A wildcard is allowed but echoed per
// request rather than as "*", so credentials keep working.
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
if len(allowed) == 0 && !wildcard {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// public marks a handler as reachable without a principal.
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
// cfg is a small accessor so handlers read the current config once.
// authed wraps a handler with authentication and, for mutations, a write
// permission check.
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.principal(r)
if err != nil {
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
// auth mode none, or a trusted loopback client: run as a local
// principal.
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
}
if write && !p.CanWrite() {
writeError(w, http.StatusForbidden, "this key is read-only")
return
}
if !s.allowRequest(w, r) {
return
}
ctx := context.WithValue(r.Context(), principalKey, p)
h(w, r.WithContext(ctx))
})
}
type ctxKey string
const principalKey ctxKey = "fancydns.principal"
// PrincipalFrom returns the authenticated principal, if any.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// principal authenticates a request.
func (s *Server) principal(r *http.Request) (Principal, error) {
// API key in the header wins: it is the machine path.
if key := r.Header.Get("X-API-Key"); key != "" {
return s.auth.apiKey(key)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, value, ok := strings.Cut(h, " ")
if !ok {
return Principal{}, errors.New("malformed Authorization header")
}
switch strings.ToLower(scheme) {
case "bearer":
return s.auth.session(value)
case "basic":
user, pass, ok := r.BasicAuth()
if !ok {
return Principal{}, errors.New("malformed basic credentials")
}
return s.auth.basic(user, pass)
}
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
}
if s.cfgTrustLoopback(r) {
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
}
return Principal{}, errors.New("no credentials")
}
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
cfg := s.hooks.Store.Get()
if !cfg.API.Auth.TrustLoopback {
return false
}
ip := s.clientIP(r)
a, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return a.IsLoopback()
}
// clientIP resolves the client address, honouring X-Forwarded-For only from a
// trusted proxy. Trusting it from anyone would let a client spoof its identity
// past the ACL and the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return host
}
if !s.isTrusted(addr) {
return addr.String()
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Left-most entry is the original client.
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if a, err := netip.ParseAddr(first); err == nil {
return a.String()
}
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.String()
}
}
return addr.String()
}
func (s *Server) isTrusted(a netip.Addr) bool {
for _, p := range s.trusted {
if p.Contains(a) {
return true
}
}
return false
}
// allowRequest applies the API rate limit per client IP.
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.PerMinute <= 0 {
return true
}
ip := s.clientIP(r)
now := s.now()
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: now}
s.limits[ip] = st
}
if now.Before(st.lockedTill) {
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
if now.Sub(st.window) >= time.Minute {
st.window = now
st.count = 0
}
st.count++
if st.count > cfg.API.RateLimit.PerMinute {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
// Opportunistic cleanup keeps the map bounded without a sweeper.
if len(s.limits) > 4096 {
for k, v := range s.limits {
if now.Sub(v.window) > 10*time.Minute {
delete(s.limits, k)
}
}
}
return true
}
// recordLoginFailure locks an IP out after repeated bad logins.
func (s *Server) recordLoginFailure(ip string) {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.Failures <= 0 {
return
}
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: s.now()}
s.limits[ip] = st
}
st.failures++
if st.failures >= cfg.API.RateLimit.Failures {
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
st.failures = 0
}
s.log.Warn("failed admin login", "client", ip)
}
func (s *Server) clearLoginFailures(ip string) {
s.limMu.Lock()
delete(s.limits, ip)
s.limMu.Unlock()
}
// protectMetrics gates /metrics behind an API key when configured.
func (s *Server) protectMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.Metrics.Protect {
next.ServeHTTP(w, r)
return
}
if _, err := s.principal(r); err != nil {
writeError(w, http.StatusUnauthorized, "metrics are protected")
return
}
next.ServeHTTP(w, r)
})
}
// ---- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
// The status line is already sent, so the only useful thing left is a
// truncated-object fallback the client can detect.
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
}
}
type apiError struct {
Error string `json:"error"`
Fields []string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
writeJSON(w, code, apiError{Error: msg, Fields: fields})
}
// decodeJSON reads a JSON body with a size cap and strict unknown-field
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
func decodeJSON(r *http.Request, v any) error {
defer func() { _ = r.Body.Close() }()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func queryBool(r *http.Request, key string) bool {
v := strings.ToLower(r.URL.Query().Get(key))
return v == "1" || v == "true" || v == "yes"
}
func queryTime(r *http.Request, key string) time.Time {
v := r.URL.Query().Get(key)
if v == "" {
return time.Time{}
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n > 1e12 {
return time.UnixMilli(n)
}
return time.Unix(n, 0)
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t
}
return time.Time{}
}
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func isFileWritable(path string) bool {
if path == "" {
return false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
// A missing file in a writable directory is still writable.
dir := path
if i := strings.LastIndexByte(dir, '/'); i > 0 {
dir = dir[:i]
}
d, derr := os.Stat(dir)
if derr != nil {
return false
}
return d.IsDir()
}
_ = f.Close()
return true
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// authenticator checks API keys, basic credentials and issued session tokens.
//
// Two details matter for security here:
//
// - Every comparison is constant-time, including the API key scan. A
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
// measure response latency, which is a real attack on a network-exposed
// admin API.
// - Keys are compared by hash, not by string, so the stored form is not a
// raw key and a timing difference between a long and short key cannot
// reveal length.
type authenticator struct {
mode string
// readOnly maps key hash -> key label.
readOnly map[string]string
// readWrite maps key hash -> key label.
readWrite map[string]string
username string
// passwordHash is the sha256 of the configured password, so plaintext
// passwords in a config file are never compared byte-by-byte.
passwordHash string
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
}
for i, k := range cfg.ReadOnlyAPIKeys {
if k == "" {
continue
}
a.readOnly[hashKey(k)] = keyLabel("ro", i)
}
for i, k := range cfg.APIKeys {
if k == "" {
continue
}
a.readWrite[hashKey(k)] = keyLabel("rw", i)
}
if cfg.Password != "" {
// A bcrypt-looking value is already a hash; a plaintext value is
// hashed so comparisons stay constant-time either way.
a.passwordHash = hashKey(cfg.Password)
}
if a.sessionTTL <= 0 {
a.sessionTTL = 12 * time.Hour
}
return a
}
func keyLabel(kind string, i int) string {
return kind + "-" + itoa(i+1)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// hashKey returns the hex sha256 of a credential.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
// apiKey resolves an API key to a principal.
func (a *authenticator) apiKey(key string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
h := hashKey(key)
if label, ok := a.readWrite[h]; ok {
return Principal{Mode: "apikey", Name: label}, nil
}
if label, ok := a.readOnly[h]; ok {
return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil
}
return Principal{}, errors.New("invalid API key")
}
// basic resolves basic credentials and, on success, issues a session token.
func (a *authenticator) basic(user, pass string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
switch a.mode {
case "basic":
if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 {
return Principal{}, errors.New("invalid username")
}
if len(a.passwordHash) == 0 {
return Principal{}, errors.New("no password configured")
}
if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 {
return Principal{}, errors.New("invalid password")
}
return Principal{Mode: "basic", Name: user}, nil
case "apikey":
// Basic auth with an API key in the password field is a common client
// convention (curl -u any:KEY).
return a.apiKey(pass)
default:
return Principal{}, errors.New("basic auth is not enabled")
}
}
// session validates a bearer token.
func (a *authenticator) session(token string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
a.mu.RLock()
s, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Principal{}, errors.New("invalid or expired session")
}
if time.Now().After(s.expires) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
return s.principal, nil
}
// issue creates a session token for a principal.
func (a *authenticator) issue(p Principal) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is fatal for a token; fall back to a token that
// will not authenticate rather than issuing something predictable.
return "", time.Time{}
}
token := hex.EncodeToString(b)
exp := time.Now().Add(a.sessionTTL)
a.mu.Lock()
a.sessions[token] = session{principal: p, expires: exp}
// Opportunistic expiry sweep: sessions are few, so a linear pass is fine.
now := time.Now()
for k, v := range a.sessions {
if now.After(v.expires) {
delete(a.sessions, k)
}
}
a.mu.Unlock()
return token, exp
}
// revoke drops a session token.
func (a *authenticator) revoke(token string) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
// Config reports the auth mode for the UI.
func (a *authenticator) Config() string {
if a == nil {
return "none"
}
return a.mode
}
// AuthMode returns the configured mode.
func (s *Server) AuthMode() string { return s.auth.Config() }
// handleLogin authenticates a login attempt and returns a session token.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
var in struct {
APIKey string `json:"api_key"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
var (
p Principal
err error
)
switch {
case in.APIKey != "":
p, err = s.auth.apiKey(in.APIKey)
case in.Username != "":
p, err = s.auth.basic(in.Username, in.Password)
default:
writeError(w, http.StatusBadRequest, "api_key or username/password required")
return
}
if err != nil {
s.recordLoginFailure(ip)
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.clearLoginFailures(ip)
token, exp := s.auth.issue(p)
if token == "" {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"token": token,
"expires_at": exp,
"name": p.Name,
"read_only": p.ReadOnly,
"mode": p.Mode,
})
}
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func cut(s, sep string) (before, after string, found bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
if len(sub) > len(s) {
return -1
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// handleWhoami reports the caller's identity, which the UI uses to decide
// whether to show write controls.
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"name": p.Name,
"mode": p.Mode,
"read_only": p.ReadOnly,
"can_write": p.CanWrite(),
"auth_mode": s.AuthMode(),
"base_path": s.base,
})
}
+701
View File
@@ -0,0 +1,701 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// metricsHandler is injected by the app so the api package does not import the
// metrics package directly (keeps the dependency one-way).
var metricsHandler func() http.Handler
// SetMetricsHandler installs the /metrics handler.
func SetMetricsHandler(h http.Handler) {
metricsHandler = func() http.Handler { return h }
}
// handleHealth is the liveness/readiness probe. It is unauthenticated on
// purpose, but deliberately reveals only a coarse status.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
h := s.hooks.Health()
code := http.StatusOK
if st, ok := h.(map[string]any); ok {
if v, _ := st["status"].(string); v == "down" {
code = http.StatusServiceUnavailable
}
}
writeJSON(w, code, h)
}
// handleStatus reports the runtime state of every component.
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": s.hooks.Version,
"uptime_s": int64(time.Since(s.hooks.StartedAt).Seconds()),
"started": s.hooks.StartedAt,
"config": map[string]any{
"version": s.hooks.Store.Get().Version,
"generation": s.hooks.Store.Generation(),
"path": s.hooks.Store.Path(),
"last_error": s.hooks.Store.LastError(),
"writable": isFileWritable(s.hooks.Store.Path()),
"reload_ready": s.hooks.Store.Path() != "",
},
"components": s.hooks.Status(),
})
}
// handleDashboard serves the aggregated dashboard payload.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if s.hooks.Dashboard == nil {
writeError(w, http.StatusNotImplemented, "dashboard not available")
return
}
v, err := s.hooks.Dashboard(r.Context(), r.URL.Query().Get("range"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleBlocklists serves the compiled blocklist state.
func (s *Server) handleBlocklists(w http.ResponseWriter, _ *http.Request) {
out := map[string]any{}
if s.hooks.BlocklistState != nil {
if m, ok := s.hooks.BlocklistState().(map[string]any); ok {
out = m
}
}
writeJSON(w, http.StatusOK, out)
}
// handleBlocklistRefresh recompiles the blocklists.
func (s *Server) handleBlocklistRefresh(w http.ResponseWriter, r *http.Request) {
if s.hooks.RefreshBlocklists == nil {
writeError(w, http.StatusNotImplemented, "blocklist refresh not available")
return
}
var in struct {
Names []string `json:"names"`
}
// An empty body is valid and means "refresh everything".
if r.ContentLength > 0 {
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
}
if err := s.hooks.RefreshBlocklists(r.Context(), in.Names); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("blocklist refresh requested", "names", in.Names)
writeJSON(w, http.StatusOK, map[string]any{"started": true, "names": in.Names})
}
// handleBlocklistToggle flips a source's enabled flag.
func (s *Server) handleBlocklistToggle(w http.ResponseWriter, r *http.Request) {
if s.hooks.ToggleBlocklist == nil {
writeError(w, http.StatusNotImplemented, "blocklist toggle not available")
return
}
var in struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &in); err != nil || in.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.ToggleBlocklist(in.Name, in.Enabled); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": in.Name, "enabled": in.Enabled})
}
// handleDomains pages the compiled rules.
func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) {
if s.hooks.ListDomains == nil {
writeError(w, http.StatusNotImplemented, "domain listing not available")
return
}
q := DomainQuery{
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
List: r.URL.Query().Get("list"),
Page: queryInt(r, "page", 1),
PerPage: queryInt(r, "per_page", 100),
}
if q.Page < 1 {
q.Page = 1
}
if q.PerPage < 1 || q.PerPage > 1000 {
q.PerPage = 100
}
v, err := s.hooks.ListDomains(q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainCheck explains how a name would be treated, which is the fastest
// way for an operator to debug "why is this not blocked".
func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
if s.hooks.DomainCheck == nil {
writeError(w, http.StatusNotImplemented, "domain check not available")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.DomainCheck(name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainAdd adds an inline rule.
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddDomain == nil {
writeError(w, http.StatusNotImplemented, "domain add not available")
return
}
var in DomainInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.AddDomain(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleDomainDelete removes a rule.
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteDomain == nil {
writeError(w, http.StatusNotImplemented, "domain delete not available")
return
}
// The route is {name...} so a rule name containing dots or a wildcard
// prefix survives without percent-encoding gymnastics.
name := r.PathValue("name")
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.hooks.DeleteDomain(r.Context(), name, r.URL.Query().Get("action"), r.URL.Query().Get("list")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name})
}
// handleClients returns the ACL groups.
func (s *Server) handleClients(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetACL == nil {
writeError(w, http.StatusNotImplemented, "acl not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetACL())
}
// aclPayload is the PUT body for the clients endpoint.
type aclPayload struct {
Groups []config.Group `json:"groups"`
DefaultPolicy string `json:"default_policy"`
}
// handleClientsPut validates and persists new ACL groups.
func (s *Server) handleClientsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutACL == nil {
writeError(w, http.StatusNotImplemented, "acl write not available")
return
}
var in aclPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
version, err := s.hooks.PutACL(r.Context(), in.Groups, in.DefaultPolicy)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("acl updated", "groups", len(in.Groups), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleUpstreams returns the upstream pool state.
func (s *Server) handleUpstreams(w http.ResponseWriter, _ *http.Request) {
if s.hooks.GetUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstreams not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.GetUpstream())
}
// upstreamPayload is the PUT body for the upstreams endpoint. Health fields
// from the UI are ignored on write: the server owns them.
type upstreamPayload struct {
Policy string `json:"policy"`
Servers []config.UpstreamServer `json:"servers"`
Bootstraps []config.UpstreamBootstrap `json:"bootstraps"`
HealthCheckSeconds float64 `json:"healthcheck_interval_s"`
MaxAttempts int `json:"max_attempts"`
BreakerThreshold int `json:"breaker_threshold"`
}
// handleUpstreamsPut validates and persists a new upstream config.
func (s *Server) handleUpstreamsPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutUpstream == nil {
writeError(w, http.StatusNotImplemented, "upstream write not available")
return
}
var in upstreamPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error())
return
}
// Preserve fields the UI does not manage, so a UI write never silently
// resets a breaker cooldown someone set by hand.
cur := s.hooks.Store.Get().Upstream
up := config.Upstream{
Policy: in.Policy,
Servers: in.Servers,
Bootstraps: in.Bootstraps,
MaxAttempts: in.MaxAttempts,
HealthCheck: config.Duration(in.HealthCheckSeconds * float64(time.Second)),
BreakerThreshold: in.BreakerThreshold,
BreakerCooldown: cur.BreakerCooldown,
}
if up.HealthCheck <= 0 {
up.HealthCheck = cur.HealthCheck
}
if up.BreakerThreshold <= 0 {
up.BreakerThreshold = cur.BreakerThreshold
}
version, err := s.hooks.PutUpstream(r.Context(), up)
if err != nil {
writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err))
return
}
s.log.Info("upstreams updated", "servers", len(up.Servers), "version", version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleLogs pages the query log.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if s.hooks.SearchLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
q := LogQuery{
Limit: queryInt(r, "limit", 200),
Offset: queryInt(r, "offset", 0),
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
Client: r.URL.Query().Get("client"),
RCode: queryInt(r, "rcode", 0),
BlockedOnly: queryBool(r, "blocked"),
Since: queryTime(r, "since"),
}
v, err := s.hooks.SearchLog(r.Context(), q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleLogStream is the Server-Sent Events endpoint feeding the live view.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// Without this, a reverse proxy buffers the stream and the UI shows
// nothing until the buffer fills.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
send := func(event string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return true
}
// A single-line data field is what the SSE grammar requires, so the
// payload is compact JSON with no newlines (json.Marshal never emits
// a raw newline).
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return false
}
flusher.Flush()
return true
}
// An initial event tells the client the stream is live, so the UI can show
// a "connected" state before any query happens.
if !send("ready", map[string]any{"at": time.Now(), "version": s.hooks.Version}) {
return
}
var (
entries <-chan LogEntry
cancel func()
)
if s.hooks.SubscribeLog != nil {
entries, cancel = s.hooks.SubscribeLog()
defer cancel()
}
stats := time.NewTicker(time.Second)
defer stats.Stop()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case e, ok := <-entries:
if !ok {
return
}
if !send("entry", e) {
return
}
case <-stats.C:
if s.hooks.LogStats == nil {
continue
}
if !send("stats", s.hooks.LogStats()) {
return
}
case <-ping.C:
// A comment frame keeps proxies from timing the stream out.
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// handleLogsClear empties the query log.
func (s *Server) handleLogsClear(w http.ResponseWriter, _ *http.Request) {
if s.hooks.ClearLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
if err := s.hooks.ClearLog(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleCache returns the cache snapshot.
func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) {
if s.hooks.CacheStats == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, s.hooks.CacheStats())
}
// handleCacheTop returns the hottest cached names.
func (s *Server) handleCacheTop(w http.ResponseWriter, r *http.Request) {
if s.hooks.CacheTop == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": s.hooks.CacheTop(queryInt(r, "limit", 50)),
})
}
// handleCacheFlush empties the cache.
func (s *Server) handleCacheFlush(w http.ResponseWriter, _ *http.Request) {
if s.hooks.FlushCache == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
n := s.hooks.FlushCache()
s.log.Info("cache flushed", "entries", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": n})
}
// handleSettingsGet returns the config document plus what is knowable about it.
func (s *Server) handleSettingsGet(w http.ResponseWriter, _ *http.Request) {
path := s.hooks.Store.Path()
var fileYAML string
if path != "" {
if b, err := os.ReadFile(path); err == nil {
fileYAML = string(b)
}
}
cur := s.hooks.Store.Get()
effective, err := config.Marshal(cur)
if err != nil {
writeError(w, http.StatusInternalServerError, "cannot render config: "+err.Error())
return
}
defaults, _ := config.Marshal(config.Default())
if fileYAML == "" {
// Running without a file: show the effective document as the editable
// text so the UI still works.
fileYAML = string(effective)
}
writeJSON(w, http.StatusOK, map[string]any{
"version": cur.Version,
"path": path,
"config_yaml": fileYAML,
"effective_yaml": string(effective),
"defaults_yaml": string(defaults),
"last_error": s.hooks.Store.LastError(),
"reload_available": path != "",
"writable": isFileWritable(path),
})
}
// settingsPayload is the PUT body for the settings endpoint.
type settingsPayload struct {
ConfigYAML string `json:"config_yaml"`
}
// handleSettingsPut validates, writes and swaps a new config.
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.API.AllowConfigWrite {
writeError(w, http.StatusForbidden, "config writes are disabled")
return
}
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.ConfigYAML) == "" {
writeError(w, http.StatusBadRequest, "config_yaml is empty")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeError(w, http.StatusBadRequest, "cannot parse config: "+err.Error())
return
}
// Relative paths in the document resolve against the config's directory,
// so a file written from a different cwd still finds its zone files.
next.WorkingDirOrSet(cfg.Server.WorkingDir)
if err := next.Validate(); err != nil {
writeErrorFields(w, http.StatusBadRequest, "invalid config: "+err.Error(), configErrorFields(err))
return
}
if err := s.commit(next); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.log.Info("config updated via api", "version", next.Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": next.Version, "warnings": []string{}})
}
// handleSettingsValidate checks a document without applying it.
func (s *Server) handleSettingsValidate(w http.ResponseWriter, r *http.Request) {
var in settingsPayload
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
next, err := parseConfigYAML(in.ConfigYAML)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"errors": []map[string]string{{"field": "config", "message": err.Error()}},
"warnings": []map[string]string{},
})
return
}
next.WorkingDirOrSet(s.hooks.Store.Get().Server.WorkingDir)
verr := next.Validate()
out := map[string]any{"ok": verr == nil, "errors": []map[string]string{}, "warnings": []map[string]string{}}
if verr != nil {
errs := make([]map[string]string, 0)
for _, f := range configErrorFields(verr) {
errs = append(errs, map[string]string{"field": fieldOf(f), "message": f})
}
out["errors"] = errs
}
if warnings := configWarnings(next); len(warnings) > 0 {
ws := make([]map[string]string, 0, len(warnings))
for _, warn := range warnings {
ws = append(ws, map[string]string{"field": fieldOf(warn), "message": warn})
}
out["warnings"] = ws
}
writeJSON(w, http.StatusOK, out)
}
// handleSettingsReload re-reads the config file from disk.
func (s *Server) handleSettingsReload(w http.ResponseWriter, _ *http.Request) {
if s.hooks.Store.Path() == "" {
writeError(w, http.StatusBadRequest, "no config file to reload from")
return
}
if err := s.hooks.Store.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, "reload failed: "+err.Error())
return
}
s.log.Info("config reloaded from disk", "version", s.hooks.Store.Get().Version)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.hooks.Store.Get().Version})
}
// commit writes the config to disk, swaps it in and notifies the cluster.
func (s *Server) commit(next *config.Config) error {
path := s.hooks.Store.Path()
if path == "" {
return fmt.Errorf("cannot persist: no config file path is known")
}
if !isFileWritable(path) {
return fmt.Errorf("cannot persist: %s is not writable", path)
}
next.Version = s.hooks.Store.Get().Version + 1
if err := next.Save(path); err != nil {
return fmt.Errorf("write config: %w", err)
}
return s.hooks.Store.Swap(next)
}
// Commit exposes the config commit path so a component (for example the ACL or
// upstream editor) goes through the same validate-write-swap flow.
func (s *Server) Commit(next *config.Config) error { return s.commit(next) }
// parseConfigYAML decodes a config document on top of the defaults, which is
// what makes a partial document from the UI editor work.
func parseConfigYAML(text string) (*config.Config, error) {
next := config.Default()
if err := yaml.Unmarshal([]byte(text), next); err != nil {
return nil, err
}
return next, nil
}
// configErrorFields splits a joined validation error into individual messages.
func configErrorFields(err error) []string {
if err == nil {
return nil
}
msg := err.Error()
parts := strings.Split(msg, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
out = append(out, msg)
}
sort.Strings(out)
return out
}
// fieldOf extracts the leading dotted path from a validation message, e.g.
// "blocklist.lists[2]: name is required" becomes "blocklist.lists[2]".
func fieldOf(msg string) string {
i := strings.Index(msg, ":")
if i <= 0 {
return "config"
}
if f := strings.TrimSpace(msg[:i]); f != "" {
return f
}
return "config"
}
// configWarnings reports non-fatal configuration smells. A config can be valid
// and still a bad idea, and this is where a validator earns its keep.
func configWarnings(cfg *config.Config) []string {
var out []string
add := func(f string, a ...any) { out = append(out, fmt.Sprintf(f, a...)) }
if cfg.API.Auth.Mode == "none" {
add("api.auth.mode: the admin API is unauthenticated; anyone who can reach it can rewrite your config")
}
if !cfg.DNS.DoT.Enabled && !cfg.DNS.DoH.Enabled {
add("dns: neither DoT nor DoH is enabled, so clients have no encrypted transport")
}
if cfg.Cache.Enabled && !cfg.Cache.Persist.Enabled {
add("cache.persist.enabled: the cache will be cold after every restart")
}
if cfg.Cache.MaxTTL.D() > 24*time.Hour {
add("cache.max_ttl: caching for more than a day serves records upstream has already forgotten")
}
if !cfg.Block.Enabled {
add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything")
} else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 &&
len(cfg.Block.BlockEntries) == 0 {
add("blocklist: enabled but no source is configured")
}
for _, l := range cfg.Block.Lists {
if strings.HasPrefix(l.URL, "http://") {
add("blocklist.lists[%s]: the source is fetched over plain http and can be tampered with in transit", l.Name)
}
if l.Refresh.D() > 0 && l.Refresh.D() < time.Hour {
add("blocklist.lists[%s]: a refresh interval under an hour is likely to get you rate-limited", l.Name)
}
if strings.EqualFold(l.Format, "rpz") && strings.ContainsAny(l.Path, "*?") {
add("blocklist.lists[%s]: an rpz source should point at a zone file, not a pattern", l.Name)
}
}
if cfg.ACL.DefaultPolicy == "allow" {
for _, g := range cfg.ACL.Groups {
if g.Match.Any {
add("acl.groups[%s]: a catch-all group with policy allow makes the ACL a no-op", g.Name)
}
}
}
if len(cfg.Upstream.Servers) == 1 {
add("upstream.servers: only one upstream is configured, so a failure of that resolver is an outage")
}
for _, u := range cfg.Upstream.Servers {
if u.InsecureSkipVerify {
add("upstream.servers[%s]: insecure_skip_verify disables TLS validation", u.Addr)
}
}
if cfg.QueryLog.Enabled && cfg.QueryLog.SampleRate == 1 && cfg.QueryLog.LogCached {
add("qlog: logging every cached query on a busy instance is a lot of disk for little value; consider lowering sample_rate")
}
if cfg.Metrics.Enabled && !cfg.Metrics.Protect {
add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics")
}
return out
}
+786
View File
@@ -0,0 +1,786 @@
// Package app wires the components together and owns the process lifecycle:
// configuration, listeners, background loops, the admin API and shutdown.
package app
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cluster"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/server"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/web"
"github.com/miekg/dns"
)
// Version is the build version, overridden at link time with -ldflags.
var Version = "dev"
// Options configures an App.
type Options struct {
// ConfigPath is the configuration file; "" runs on defaults.
ConfigPath string
// Version is the reported build version.
Version string
// LogLevel and LogFormat override the config's logging settings.
LogLevel string
LogFormat string
}
// App is the running process.
type App struct {
opt Options
log *slog.Logger
cfg *config.Store
ver string
blocks *blocklist.Manager
cache *cache.Manager
cstore *cache.Store
logger *qlog.Logger
aclH *acl.Holder
local *localzone.Holder
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
series *seriesStore
apiServers []*http.Server
apiLn []net.Listener
// reloadMu serialises reload work, since SIGHUP, the API and the cluster
// can all trigger one at the same time.
reloadMu sync.Mutex
startedAt time.Time
// degraded is set when a background component failed; surfaced in health.
degradedMu sync.RWMutex
degraded []string
}
// New builds an App from options. It does not bind anything yet; call Start.
func New(opt Options) (*App, error) {
if opt.Version == "" {
opt.Version = Version
}
cfg, err := config.Load(opt.ConfigPath)
if err != nil {
return nil, err
}
log, err := newLogger(cfg, opt)
if err != nil {
return nil, err
}
store := config.NewStore(cfg, opt.ConfigPath)
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil
}
// newLogger builds the structured logger from config plus CLI overrides.
func newLogger(cfg *config.Config, opt Options) (*slog.Logger, error) {
level := cfg.Log.Level
if opt.LogLevel != "" {
level = opt.LogLevel
}
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
format := cfg.Log.Format
if opt.LogFormat != "" {
format = opt.LogFormat
}
handlerOpts := &slog.HandlerOptions{Level: lvl}
var w *os.File = os.Stderr
if cfg.Log.Addr != "" {
if strings.HasPrefix(cfg.Log.Addr, "/") {
f, err := os.OpenFile(cfg.Log.Addr, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
w = f
}
}
var h slog.Handler
if format == "json" {
h = slog.NewJSONHandler(w, handlerOpts)
} else {
h = slog.NewTextHandler(w, handlerOpts)
}
return slog.New(h).With("service", "fancy-dns", "version", opt.Version), nil
}
// Start brings up every component in dependency order.
func (a *App) Start(ctx context.Context) error {
a.startedAt = time.Now()
cfg := a.cfg.Get()
metrics.SetUp(true)
if err := a.startCache(cfg); err != nil {
return err
}
if err := a.startLogger(cfg); err != nil {
return err
}
a.startBlocklist()
a.startLocalZones(cfg)
a.startUpstream(cfg)
a.startDNS(ctx)
if err := a.startAPI(ctx); err != nil {
return err
}
a.startCluster(ctx, cfg)
a.startBackground(ctx, cfg)
a.cfg.OnSwap(a.onConfigSwap)
// The real bound admin address, not the configured one: with :0 the config
// is useless for telling the operator where to point a browser.
a.log.Info("fancy-dns started",
"config", a.cfg.Path(),
"dns", a.dns.Listeners(),
"api", a.Addr())
return nil
}
// startCache opens the bbolt snapshot and restores a warm start.
func (a *App) startCache(cfg *config.Config) error {
opts := cache.Options{
Size: cfg.Cache.Size,
Shards: cfg.Cache.Shards,
MinTTL: cfg.Cache.MinTTL.D(),
MaxTTL: cfg.Cache.MaxTTL.D(),
NegativeTTL: cfg.Cache.NegativeTTL.D(),
ServeStale: cfg.Cache.ServeStale.D(),
Prefetch: cfg.Cache.Prefetch,
}
if cfg.Cache.Enabled && cfg.Cache.Persist.Enabled {
st, err := cache.OpenStore(cache.StoreOptions{
Path: cfg.CachePath(),
MaxEntries: cfg.Cache.Persist.MaxEntries,
})
if err != nil {
a.markDegraded("cache persistence: " + err.Error())
a.log.Warn("cache persistence unavailable, continuing without it", "error", err)
} else {
a.cstore = st
if cfg.Cache.Persist.LoadOnStart {
lru := cache.NewLRU(cfg.Cache.Size, cfg.Cache.Shards)
loaded, skipped, lerr := st.Load(lru, time.Now())
if lerr != nil {
a.log.Warn("cache snapshot load failed", "error", lerr)
} else if loaded > 0 {
a.log.Info("cache snapshot restored", "entries", loaded, "skipped", skipped)
// Re-seed the real LRU from the temporary one.
a.cache = cache.NewManager(opts, st)
lru.Walk(func(k cache.Key, e *cache.Entry) bool {
a.cache.PutRaw(k, e.Wire, e.TTL, e.Origin, e.Upstream, e.Neg)
return true
})
}
}
}
}
if a.cache == nil {
a.cache = cache.NewManager(opts, a.cstore)
}
return nil
}
// prewarm resolves a few names at boot in the background, so a restart does
// not look like an outage for the names that matter most.
func (a *App) prewarm(c context.Context, specs []string) {
go func() {
for _, spec := range specs {
name, qtypeStr, ok := strings.Cut(spec, "/")
if !ok {
name, qtypeStr = spec, "A"
}
qtype, ok := dns.StringToType[strings.ToUpper(qtypeStr)]
if !ok {
a.log.Warn("prewarm: unknown record type", "spec", spec)
continue
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
res := a.engine.Handle(c, engine.Request{Msg: m, Transport: "internal", Start: time.Now()})
if res.Msg != nil && res.Msg.Rcode == dns.RcodeSuccess {
a.log.Debug("prewarmed", "name", name, "type", qtypeStr)
}
}
}()
}
// tlsCfgType aliases the TLS config so the listener helpers read cleanly.
type tlsCfgType = tls.Config
// apiTLS builds the admin listener's TLS config when one is configured.
func apiTLS(cfg *config.Config) (*tls.Config, error) {
if cfg.API.CertFile == "" || cfg.API.KeyFile == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(cfg.API.CertFile, cfg.API.KeyFile)
if err != nil {
return nil, fmt.Errorf("load admin certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
}, nil
}
// startLogger opens the query log.
func (a *App) startLogger(cfg *config.Config) error {
opts := qlog.DefaultOptions()
opts.Path = cfg.QueryLogPath()
opts.MaxEntries = cfg.QueryLog.MaxEntries
opts.LiveBuffer = cfg.QueryLog.LiveBuffer
opts.FlushInterval = cfg.QueryLog.FlushInterval.D()
opts.SampleRate = cfg.QueryLog.SampleRate
opts.LogBlocked = cfg.QueryLog.LogBlocked
opts.LogCached = cfg.QueryLog.LogCached
opts.LogErrors = cfg.QueryLog.LogErrors
opts.TopDomains = cfg.QueryLog.TopDomains
opts.TopTTL = cfg.QueryLog.TopTTL.D()
if !cfg.QueryLog.Enabled {
opts.Path = ""
}
l, err := qlog.New(opts)
if err != nil {
a.markDegraded("query log: " + err.Error())
a.log.Warn("query log unavailable, continuing without persistence", "error", err)
return nil
}
a.logger = l
return nil
}
// startBlocklist starts the refresh loop and does the first compile.
func (a *App) startBlocklist() {
cfg := a.cfg.Get()
if !cfg.Block.Enabled {
a.log.Info("blocklists disabled")
return
}
a.blocks = blocklist.NewManager(blocklist.ManagerOptions{
Config: a.cfg,
Logger: a.log,
})
// Compile once before serving so the first queries are not unprotected.
if _, err := a.blocks.RefreshAll(context.Background()); err != nil {
a.markDegraded("blocklist: " + err.Error())
a.log.Warn("initial blocklist compile failed", "error", err)
}
}
// startLocalZones compiles the local master files.
func (a *App) startLocalZones(cfg *config.Config) {
a.local = localzone.NewHolder(nil)
if len(cfg.Block.LocalZones) == 0 {
return
}
a.reloadLocalZones()
}
// reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() {
cfg := a.cfg.Get()
if len(cfg.Block.LocalZones) == 0 {
a.local.Set(nil)
return
}
paths := make([]string, 0, len(cfg.Block.LocalZones))
for _, p := range cfg.Block.LocalZones {
paths = append(paths, cfg.AbsPath(p))
}
store, err := localzone.Load(paths, localzone.Options{})
if err != nil {
a.markDegraded("local zones: " + err.Error())
a.log.Error("local zone load failed, keeping previous data", "error", err)
return
}
a.local.Set(store)
metrics.SetLocalRecords(store.Count())
a.log.Info("local zones loaded", "zones", store.Zones(), "records", store.Count())
}
// startUpstream builds the resolver pool.
func (a *App) startUpstream(cfg *config.Config) {
a.pool = resolver.NewPool(cfg.Upstream)
}
// startDNS builds the engine and the listeners.
func (a *App) startDNS(ctx context.Context) {
a.aclH = acl.NewHolder(acl.Compile(cfgACL(a.cfg.Get())))
a.reverse = engine.NewReverseCache(engine.ReverseOptions{Max: 8192})
a.engine = engine.New(engine.Options{
Config: a.cfg,
Logger: a.logger,
Blocklists: a.blocksHolder(),
Cache: a.cache,
ACL: a.aclH,
Local: a.local,
Pool: a.pool,
Reverse: a.reverse,
})
a.engine.SetFlusher(a.flush)
a.dns = server.New(server.Options{
Engine: a.engine,
Config: a.cfg,
Logger: a.log,
Version: a.ver,
})
a.dns.SetIdentity(
func(ip string) []string { names, _ := a.reverse.Names(ip); return names },
func(ip string) []string {
for _, n := range mustNames(a.reverse, ip) {
if tags := acl.TagsFromName(n); len(tags) > 0 {
return tags
}
}
return nil
},
)
if err := a.dns.Start(ctx); err != nil {
a.log.Error("dns listeners failed to start", "error", err)
a.markDegraded("dns listeners: " + err.Error())
}
a.series = newSeriesStore()
}
func mustNames(rc *engine.ReverseCache, ip string) []string {
names, _ := rc.Names(ip)
return names
}
func (a *App) blocksHolder() *blocklist.Holder {
if a.blocks == nil {
return blocklist.NewHolder(nil)
}
return a.blocks.Holder()
}
func cfgACL(cfg *config.Config) config.ACL { return cfg.ACL }
// startAPI binds the admin listener and, when configured, the embedded UI.
func (a *App) startAPI(ctx context.Context) error {
cfg := a.cfg.Get()
if !cfg.API.Enabled {
return nil
}
api.SetMetricsHandler(metrics.Handler())
hooks := a.hooks()
srv, err := api.New(hooks)
if err != nil {
return err
}
a.api = srv
// One mux: the API claims its base path, the SPA claims the rest. The API
// is registered first so /api/* never falls through to the SPA's
// index.html fallback (which would return HTML to a JSON client).
mux := http.NewServeMux()
// The api package already registers its routes with the full base path, so
// no StripPrefix here: a more specific pattern than the SPA's "/" wins, so
// /api/* never falls through to index.html.
mux.Handle(cfg.API.BasePath+"/", srv.Handler())
mux.Handle(cfg.API.BasePath, srv.Handler())
if cfg.API.UI {
ui, err := web.Handler("")
if err != nil {
a.log.Warn("web console unavailable", "error", err)
} else {
mux.Handle("/", ui)
if web.Available() {
a.log.Info("web console enabled")
} else {
a.log.Warn("web console assets missing from this binary; run 'make web' and rebuild")
}
}
}
tlsCfg, tlsErr := apiTLS(cfg)
var errs []error
for _, addr := range cfg.API.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind api %s: %w", addr, err))
continue
}
a.startHTTPServer(mux, ln, nil, "api")
}
if len(cfg.API.TLSListen) > 0 {
if tlsErr != nil {
errs = append(errs, tlsErr)
} else {
for _, addr := range cfg.API.TLSListen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind api tls %s: %w", addr, err))
continue
}
a.startHTTPServer(mux, ln, tlsCfg, "api-tls")
}
}
}
if err := errors.Join(errs...); err != nil {
a.markDegraded("admin api: " + err.Error())
a.log.Error("admin api failed to bind", "error", err)
}
_ = ctx
return nil
}
func (a *App) startHTTPServer(h http.Handler, ln net.Listener, tlsCfg *tlsCfgType, name string) {
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if tlsCfg != nil {
srv.TLSConfig = tlsCfg
ln = tls.NewListener(ln, tlsCfg)
}
a.apiServers = append(a.apiServers, srv)
a.apiLn = append(a.apiLn, ln)
a.log.Info("admin api listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil, "name", name)
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.log.Error("admin api stopped", "error", err, "name", name)
}
}()
}
// startCluster connects to Valkey and wires cluster events into a reload.
func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
if !cfg.Cluster.Enabled {
a.cl = cluster.New(cluster.Options{Config: a.cfg, Logger: a.log, Version: a.ver})
return
}
a.cl = cluster.New(cluster.Options{
Config: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Addr: cfg.Cluster.Advertise,
StateFunc: a.nodeState,
Digests: a.nodeDigests,
Handler: a.onClusterEvent,
})
a.cl.Start(ctx)
}
// startBackground runs the periodic jobs: cache snapshots, upstream health
// probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
// Cache snapshots.
if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D()
if interval <= 0 {
interval = 30 * time.Second
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
a.snapshotCache()
return
case <-t.C:
a.snapshotCache()
}
}
}()
}
// Upstream health probes.
go a.healthLoop(ctx)
// Live gauges and the dashboard series sampler.
go func() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
sample := time.NewTicker(5 * time.Second)
defer sample.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
metrics.SetConcurrent(int(a.engine.Inflight()))
case <-sample.C:
if a.series != nil {
a.series.sample()
}
}
}
}()
}
func (a *App) snapshotCache() {
if a.cstore == nil || a.cache == nil {
return
}
n, err := a.cstore.Snapshot(a.cache.LRU(), time.Now())
if err != nil {
a.log.Warn("cache snapshot failed", "error", err)
return
}
a.log.Debug("cache snapshot written", "entries", n)
}
func (a *App) healthLoop(ctx context.Context) {
for {
interval := a.cfg.Get().Upstream.HealthCheck.D()
if interval <= 0 {
interval = 30 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
pool := a.pool
if pool == nil {
continue
}
hctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ok := pool.HealthCheck(hctx, 3*time.Second)
cancel()
total := len(pool.All())
for _, u := range pool.All() {
healthy := u.Health() == resolver.HealthUp
metrics.SetUpstreamHealth(u.Addr(), u.Config().Proto, healthy)
}
if total > 0 && ok == 0 {
a.markDegraded("no upstream is answering health probes")
} else {
a.clearDegraded("no upstream is answering health probes")
}
}
}
// markDegraded records a component problem for the health endpoint.
func (a *App) markDegraded(reason string) {
a.degradedMu.Lock()
defer a.degradedMu.Unlock()
for _, r := range a.degraded {
if r == reason {
return
}
}
a.degraded = append(a.degraded, reason)
}
func (a *App) clearDegraded(reason string) {
a.degradedMu.Lock()
defer a.degradedMu.Unlock()
out := a.degraded[:0]
for _, r := range a.degraded {
if r != reason {
out = append(out, r)
}
}
a.degraded = out
}
// Degraded returns the current problem list.
func (a *App) Degraded() []string {
a.degradedMu.RLock()
defer a.degradedMu.RUnlock()
return append([]string(nil), a.degraded...)
}
// onConfigSwap reacts to a new configuration.
func (a *App) onConfigSwap(cfg *config.Config) {
a.log.Info("configuration applied", "version", cfg.Version)
if a.pool != nil {
a.pool.Rebind(cfg.Upstream)
}
a.reloadLocalZones()
if a.blocks != nil && !cfg.Block.Enabled {
a.log.Info("blocklists disabled by configuration change")
}
}
// onClusterEvent reacts to a peer message.
func (a *App) onClusterEvent(ctx context.Context, ev cluster.Event) {
switch ev.Kind {
case cluster.EventReload:
a.log.Info("peer requested a config reload", "from", ev.NodeID)
if err := a.Reload(); err != nil {
a.log.Warn("cluster-triggered reload failed", "error", err)
}
case cluster.EventBlocklists:
// Only the lease holder fetches, so N instances do not hammer a mirror
// N times; the others keep serving their compiled copy.
if a.cl != nil && a.cl.IsLeader() {
if a.blocks != nil {
a.blocks.Trigger(firstOr(ev.Sources, ""))
}
}
case cluster.EventConfig:
// A peer pushed a full document. We do not adopt it silently: a
// follower adopting a document it cannot see on disk would drift. The
// operator re-runs a deploy or hits "reload" instead.
a.log.Info("peer published a new config version", "from", ev.NodeID, "version", ev.Version)
}
}
func firstOr(s []string, def string) string {
if len(s) > 0 && s[0] != "" {
return s[0]
}
return def
}
// nodeState supplies the live state published to peers.
func (a *App) nodeState() *cluster.NodeState {
addrs := make([]string, 0, 4)
for _, l := range a.dns.Listeners() {
addrs = append(addrs, l.Transport+":"+l.Addr)
}
return &cluster.NodeState{
Addr: a.opt.ConfigPath,
Version: a.ver,
StartedAt: a.startedAt,
Listeners: addrs,
Generation: a.cfg.Generation(),
}
}
// nodeDigests supplies the digests peers compare.
func (a *App) nodeDigests() (string, int, time.Time, string) {
var (
bl string
rules int
built time.Time
)
if a.blocks != nil {
st := a.blocks.State()
if st != nil {
bl = st.Digest
if st.Set != nil {
rules = st.Set.Len()
built = st.Set.BuiltAt()
}
}
}
return bl, rules, built, cluster.Digest(a.cfg.Get())
}
// Reload re-reads the config file and applies it.
func (a *App) Reload() error {
a.reloadMu.Lock()
defer a.reloadMu.Unlock()
return a.cfg.Reload()
}
// Stop shuts everything down in reverse order, with a deadline.
func (a *App) Stop(ctx context.Context) error {
a.log.Info("shutting down")
metrics.SetUp(false)
var errs []error
if a.cl != nil {
a.cl.Stop()
}
if a.blocks != nil {
a.blocks.Stop()
}
if a.dns != nil {
if err := a.dns.Stop(ctx); err != nil {
errs = append(errs, err)
}
}
for _, srv := range a.apiServers {
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := srv.Shutdown(shutCtx)
cancel()
if err != nil {
errs = append(errs, err)
}
}
if a.logger != nil {
a.logger.Flush()
if err := a.logger.Close(); err != nil {
errs = append(errs, err)
}
}
if a.cstore != nil {
// Snapshot, close, then clear the pointer: the background snapshot
// goroutine also fires on ctx.Done, and a nil check is how it learns
// to stop rather than writing to a closed file.
a.snapshotCache()
if err := a.cstore.Close(); err != nil {
errs = append(errs, err)
}
a.cstore = nil
}
if err := errors.Join(errs...); err != nil {
a.log.Warn("shutdown completed with errors", "error", err)
} else {
a.log.Info("shutdown complete")
}
return errors.Join(errs...)
}
// Addr returns the admin API address, which a preview needs.
func (a *App) Addr() string {
for _, ln := range a.apiLn {
return ln.Addr().String()
}
return ""
}
// DataDir is where the bbolt files live.
func (a *App) DataDir() string {
return filepath.Dir(a.cfg.Get().CachePath())
}
// Log exposes the logger so the command can report signals.
func (a *App) Log() *slog.Logger { return a.log }
// SetAdminAddr overrides the admin API listen address without touching the
// config file, which is what a container platform's -admin-addr flag wants.
func (a *App) SetAdminAddr(addr string) error {
next := a.cfg.Get()
cp := *next
cp.API = next.API
cp.API.Listen = []string{addr}
if err := a.cfg.Swap(&cp); err != nil {
return err
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package app
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// testUpstream is a fake recursive resolver: it answers A queries for names
// under test. and NXDOMAIN for everything else, so a test can tell "resolved
// upstream" from "blocked locally" without touching the internet.
type testUpstream struct {
*dns.Server
addr string
mu sync.Mutex
hits map[string]int
}
func newTestUpstream(t *testing.T) *testUpstream {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
u := &testUpstream{addr: pc.LocalAddr().String(), hits: map[string]int{}}
u.Server = &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(u.handle)}
go func() { _ = u.ActivateAndServe() }()
t.Cleanup(func() { _ = u.Shutdown() })
return u
}
func (u *testUpstream) handle(w dns.ResponseWriter, q *dns.Msg) {
m := new(dns.Msg)
m.SetReply(q)
name := q.Question[0].Name
u.mu.Lock()
u.hits[name]++
u.mu.Unlock()
if strings.HasSuffix(name, ".test.") {
if q.Question[0].Qtype == dns.TypeA {
rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.7")
m.Answer = append(m.Answer, rr)
}
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
} else {
m.Rcode = dns.RcodeNameError
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
}
_ = w.WriteMsg(m)
}
func (u *testUpstream) hitCount(name string) int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits[name]
}
// harness is a fully wired App on ephemeral ports, with a fake upstream.
type harness struct {
t *testing.T
app *App
addr string // host:port for plain DNS
api string // host:port for the admin API
key string
up *testUpstream
dir string
// config is the document the harness wrote to disk, so a test can make a
// realistic edit rather than inventing a minimal stub.
config string
}
// OriginalConfig returns the document the harness wrote to disk, so a test
// can make a realistic single-value edit instead of inventing a minimal stub.
func (h *harness) OriginalConfig() string { return h.config }
func newHarness(t *testing.T, mutate func(*config.Config)) *harness {
t.Helper()
dir := t.TempDir()
up := newTestUpstream(t)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(
"0.0.0.0 ads.example.com\n"+
"0.0.0.0 tracker.example.net\n"+
"0.0.0.0 shared.example.com\n"+
// Under .test. so the fake upstream resolves it: NOERROR then means
// "the blocklist let it through" and NXDOMAIN means "blocked", with
// no third possibility to confuse an assertion.
"0.0.0.0 blocked.example.test\n"+
"# comment\n"+
"127.0.0.1 localhost\n"), 0o644); err != nil {
t.Fatal(err)
}
zone := filepath.Join(dir, "local.zone")
if err := os.WriteFile(zone, []byte(
"$TTL 60\n"+
"local. SOA ns.local. hostmaster.local. 1 3600 600 604800 60\n"+
"local. NS ns.local.\n"+
"ns.local. A 192.168.1.2\n"+
"*.wild.local. A 192.168.1.50\n"+
"deep.svc.local. A 192.168.1.60\n"+
"*.deep.svc.local. A 192.168.1.61\n"), 0o644); err != nil {
t.Fatal(err)
}
// Pick a free port up front so the client can address the listeners and so
// a port clash fails the test rather than the app's start-up.
dnsPort := freePort(t)
dnsAddr := fmt.Sprintf("127.0.0.1:%d", dnsPort)
cfg := config.Default()
cfg.WorkingDirOrSet(dir)
cfg.DNS.UDP = []string{dnsAddr}
cfg.DNS.TCP = []string{dnsAddr}
cfg.DNS.DoT.Enabled = false
cfg.DNS.DoH.Enabled = false
cfg.API.Listen = []string{"127.0.0.1:0"}
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"test-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
cfg.Upstream.Policy = "parallel"
cfg.Upstream.MaxAttempts = 1
cfg.Upstream.HealthCheck = 0
cfg.Upstream.Servers = []config.UpstreamServer{{Addr: up.addr, Proto: "udp", Weight: 1, HealthName: "test"}}
cfg.Cache.Persist.Path = filepath.Join(dir, "cache.db")
cfg.QueryLog.Path = filepath.Join(dir, "queries.db")
cfg.Block.Lists = []config.ListSource{{
Name: "test-hosts", Format: "hosts", Path: hosts, Enabled: true, Refresh: 0,
}}
cfg.Block.LocalZones = []string{zone}
cfg.Block.BlockEntries = []string{"inline-blocked.example.com", "*.inline-sub.example.com"}
cfg.Block.AllowEntries = []string{"allow.example.com"}
if mutate != nil {
mutate(cfg)
}
path := filepath.Join(dir, "config.yaml")
// Snapshot the document before the app starts: the server may rewrite this
// file (every API mutation does), so a test must not read it concurrently
// with a live instance's own writes.
doc := string(mustYAML(t, cfg))
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
a, err := New(Options{ConfigPath: path, Version: "test"})
if err != nil {
t.Fatalf("new app: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := a.Start(ctx); err != nil {
cancel()
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
sctx, scancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = a.Stop(sctx)
scancel()
cancel()
})
return &harness{t: t, app: a, addr: dnsAddr, api: a.Addr(), key: "test-key", up: up, dir: dir, config: doc}
}
// freePort returns a currently-free TCP port on the loopback interface.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
func (h *harness) query(name string, qtype uint16) *dns.Msg {
h.t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
h.t.Fatalf("query %s: %v", name, err)
}
return res
}
func (h *harness) get(path string) (int, []byte) {
h.t.Helper()
req, _ := http.NewRequest("GET", "http://"+h.api+path, nil)
req.Header.Set("X-API-Key", h.key)
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("GET %s: %v", path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func (h *harness) do(method, path, body string) (int, []byte) {
h.t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, _ := http.NewRequest(method, "http://"+h.api+path, rdr)
req.Header.Set("X-API-Key", h.key)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("%s %s: %v", method, path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func mustYAML(t *testing.T, c *config.Config) []byte {
t.Helper()
b, err := config.Marshal(c)
if err != nil {
t.Fatal(err)
}
return b
}
var _ = base64.StdEncoding
var _ = tls.VersionTLS13
+922
View File
@@ -0,0 +1,922 @@
package app
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
)
// hooks builds the api.Hooks that connect the admin API to the live
// components. Everything the UI can read or change goes through here.
func (a *App) hooks() api.Hooks {
return api.Hooks{
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Dashboard: a.dashboard,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
SearchLog: a.searchLog,
LogStats: a.logStats,
SubscribeLog: a.subscribeLog,
ClearLog: a.clearLog,
CacheStats: a.cacheStats,
CacheTop: a.cacheTop,
FlushCache: a.flushCache,
Flush: a.flush,
}
}
// health is the unauthenticated probe payload.
func (a *App) health() any {
status := "ok"
problems := a.Degraded()
switch {
case len(problems) > 0:
status = "degraded"
}
if a.dns == nil || len(a.dns.Listeners()) == 0 {
status = "down"
}
if a.logger != nil && a.logger.ErrorCount() > 0 && status == "ok" {
status = "degraded"
problems = append(problems, "query log write errors")
}
return map[string]any{
"status": status,
"version": a.ver,
"uptime_s": int64(time.Since(a.startedAt).Seconds()),
"config_version": a.cfg.Get().Version,
"problems": problems,
}
}
// status is the per-component runtime view.
func (a *App) status() any {
out := map[string]any{
"listeners": a.dns.Listeners(),
"transports": a.dns.ByTransport(),
"degraded": a.Degraded(),
}
if a.blocks != nil {
st := a.blocks.State()
if st != nil {
out["blocklist"] = map[string]any{
"rules": st.Set.Len(),
"sources": len(st.Sources),
"duration": st.Duration,
"built_at": st.Set.BuiltAt(),
"errors": st.Errors,
"digest": st.Digest,
"has_wild": st.Set.HasWildcards(),
"duplicates": st.Set.DuplicatesN(),
}
}
}
if a.local != nil {
if store := a.local.Get(); store != nil {
out["local_zones"] = map[string]any{
"zones": store.Zones(),
"records": store.Count(),
"files": store.Files(),
}
}
}
if a.cache != nil {
out["cache"] = a.cacheStats()
}
if a.logger != nil {
out["qlog"] = a.logger.Stats()
}
if a.pool != nil {
out["upstream"] = a.getUpstreams()
}
if a.aclH != nil {
out["acl"] = a.getACL()
}
if a.cl != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
out["cluster"] = a.cl.Status(ctx)
cancel()
}
if a.engine != nil {
out["engine"] = map[string]any{
"inflight": a.engine.Inflight(),
"dropped": a.engine.Dropped(),
"group_stats": a.engine.GroupStats(),
}
}
return out
}
// blocklistState is the full per-source view for the UI.
func (a *App) blocklistState() any {
if a.blocks == nil {
return map[string]any{
"items": []any{},
"stats": map[string]any{"total": 0},
}
}
st := a.blocks.State()
items := make([]blocklist.Source, 0, len(st.Sources))
for _, s := range st.Sources {
items = append(items, s)
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return map[string]any{
"items": items,
"stats": blocklistSourceStats(st),
"errors": st.Errors,
"digest": st.Digest,
}
}
func blocklistSourceStats(st *blocklist.State) map[string]any {
if st == nil || st.Set == nil {
return map[string]any{"total": 0}
}
s := st.Set.Stats()
return map[string]any{
"total": s.Total,
"exact": s.Exact,
"subdomain_rules": s.Subdomains,
"root_rules": s.RootRules,
"allow": s.Allow,
"block": s.Block,
"passthrough": s.Passthru,
"drop": s.Drop,
"duplicates": s.Duplicates,
"sources": s.Sources,
"built_at": s.BuiltAt,
"sources_detail": s.PerSource,
}
}
func (a *App) blocklistStats() any {
if a.blocks == nil {
return map[string]any{"total": 0}
}
return blocklistSourceStats(a.blocks.State())
}
// invalidateBlocklistCache drops every cached answer whose verdict the new
// rules might change. Without this, a name that has just become blocked keeps
// serving its cached good answer until the TTL expires, which is exactly the
// bug an operator hits right after clicking "add rule".
func (a *App) invalidateBlocklistCache() {
if a.cache == nil {
return
}
a.cache.Flush()
}
// refreshBlocklists recompiles the list sources.
func (a *App) refreshBlocklists(ctx context.Context, names []string) error {
if a.blocks == nil {
return fmt.Errorf("blocklists are disabled")
}
a.invalidateBlocklistCache()
go func() {
if len(names) == 0 {
if _, err := a.blocks.RefreshAll(ctx); err != nil {
a.log.Warn("blocklist refresh failed", "error", err)
}
return
}
// The manager compiles everything in one pass, which is cheaper than
// a pass per name and keeps the compiled set internally consistent.
if _, err := a.blocks.RefreshAll(ctx); err != nil {
a.log.Warn("blocklist refresh failed", "error", err, "names", names)
}
}()
// Tell the cluster leader so every instance ends up on the same content.
if a.cl != nil {
_ = a.cl.BroadcastRefresh(ctx, names)
}
return nil
}
// toggleBlocklist flips one source's enabled flag and persists it.
func (a *App) toggleBlocklist(name string, enabled bool) error {
if a.blocks == nil {
return fmt.Errorf("blocklists are disabled")
}
err := a.mutateConfig(func(c *config.Config) error {
found := false
for i := range c.Block.Lists {
if c.Block.Lists[i].Name == name {
c.Block.Lists[i].Enabled = enabled
found = true
}
}
if !found {
return fmt.Errorf("no blocklist named %q", name)
}
return nil
})
if err != nil {
return err
}
a.invalidateBlocklistCache()
go func() {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after toggle failed", "error", rerr)
}
}()
return nil
}
// mutateConfig clones, mutates, validates and commits the config, returning
// the validation error when the change is rejected.
func (a *App) mutateConfig(fn func(*config.Config) error) error {
cur := a.cfg.Get()
next := *cur
// A shallow copy is not enough: the slices and maps we touch need their own
// backing arrays, or a rejected change would corrupt the live config.
next.Block = cur.Block
next.Block.Lists = append([]config.ListSource(nil), cur.Block.Lists...)
next.Block.LocalZones = append([]string(nil), cur.Block.LocalZones...)
next.Block.BlockEntries = append([]string(nil), cur.Block.BlockEntries...)
next.Block.AllowEntries = append([]string(nil), cur.Block.AllowEntries...)
next.ACL = cur.ACL
next.ACL.Groups = append([]config.Group(nil), cur.ACL.Groups...)
next.Upstream = cur.Upstream
next.Upstream.Servers = append([]config.UpstreamServer(nil), cur.Upstream.Servers...)
next.Upstream.Bootstraps = append([]config.UpstreamBootstrap(nil), cur.Upstream.Bootstraps...)
if err := fn(&next); err != nil {
return err
}
if err := next.Validate(); err != nil {
return err
}
if a.api == nil {
return a.cfg.Swap(&next)
}
return a.api.Commit(&next)
}
// domainCheck explains how a name would be treated.
func (a *App) domainCheck(name string) (any, error) {
entry := (*blocklist.Entry)(nil)
if a.blocks != nil {
if set := a.blocks.Holder().Get(); set != nil {
entry = set.Match(name)
}
}
out := map[string]any{
"name": name,
"query": strings.TrimSuffix(name, "."),
}
if entry == nil {
out["matched"] = false
out["action"] = "none"
out["source"] = ""
return out, nil
}
kind := "exact"
pattern := entry.Name
if set := a.blocks.Holder().Get(); set != nil {
// Recover which of the three maps matched so the UI can say *why*.
switch set.MatchPattern(name) {
case blocklist.PatternSub:
kind = "subdomain"
pattern = "*." + entry.Name
case blocklist.PatternRoot:
kind = "domain"
pattern = "**." + entry.Name
}
}
out["matched"] = true
out["action"] = entry.Action.String()
out["source"] = entry.Source
out["categories"] = entry.Categories
out["hits"] = entry.Hits()
out["rule"] = pattern
out["matched_pattern"] = pattern
out["match_kind"] = kind
return out, nil
}
// listDomains pages the compiled rules. Walking a million-entry map on every
// request would be too slow, so the filter runs over a bounded sample unless
// the query is a prefix/exact lookup, which the LRU can answer directly.
func (a *App) listDomains(q api.DomainQuery) (any, error) {
if a.blocks == nil {
return map[string]any{"items": []any{}, "total": 0, "page": 1, "per_page": q.PerPage}, nil
}
set := a.blocks.Holder().Get()
if set == nil {
return map[string]any{"items": []any{}, "total": 0, "page": 1, "per_page": q.PerPage}, nil
}
if q.Text != "" && (q.Action == "" && q.List == "") {
if e := set.Lookup(q.Text); e != nil {
return map[string]any{
"items": []blocklist.Entry{*e},
"total": 1,
"page": 1,
"per_page": q.PerPage,
}, nil
}
}
type row struct {
Name string `json:"name"`
Action string `json:"action"`
Source string `json:"source"`
Categories []string `json:"categories"`
Hits uint64 `json:"hits"`
Rule string `json:"rule"`
}
text := strings.ToLower(strings.TrimSpace(q.Text))
action := strings.ToLower(q.Action)
// The cap keeps a request bounded on a large set; the UI pages anyway.
const scanCap = 400000
matches := make([]row, 0, 256)
total := 0
set.Each(func(pattern string, e *blocklist.Entry, kind blocklist.PatternKind) bool {
if len(matches) >= scanCap {
return false
}
if action != "" && e.Action.String() != action {
return true
}
if q.List != "" && e.Source != q.List {
return true
}
if text != "" && !strings.Contains(pattern, text) {
return true
}
total++
matches = append(matches, row{
Name: pattern,
Action: e.Action.String(),
Source: e.Source,
Categories: e.Categories,
Hits: e.Hits(),
Rule: kind.Pattern(pattern),
})
return true
})
sort.Slice(matches, func(i, j int) bool {
if matches[i].Hits != matches[j].Hits {
return matches[i].Hits > matches[j].Hits
}
return matches[i].Name < matches[j].Name
})
start := (q.Page - 1) * q.PerPage
if start > len(matches) {
start = len(matches)
}
end := start + q.PerPage
if end > len(matches) {
end = len(matches)
}
return map[string]any{
"items": matches[start:end],
"total": total,
"page": q.Page,
"per_page": q.PerPage,
"truncated": total > len(matches),
}, nil
}
// addDomain appends an inline rule and persists it.
func (a *App) addDomain(_ context.Context, in api.DomainInput) (any, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, fmt.Errorf("name is required")
}
act, ok := blocklist.ParseAction(in.Action)
if !ok {
return nil, fmt.Errorf("unknown action %q", in.Action)
}
// A bulk paste goes in as several entries; a single name goes in as one.
entries := []string{}
if in.Content != "" {
entries = strings.Split(in.Content, "\n")
} else {
entries = []string{name}
}
err := a.mutateConfig(func(c *config.Config) error {
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" || strings.HasPrefix(e, "#") {
continue
}
entry := e
if act == blocklist.ActionBlock {
c.Block.BlockEntries = append(c.Block.BlockEntries, entry)
} else {
c.Block.AllowEntries = append(c.Block.AllowEntries, entry)
}
}
return nil
})
if err != nil {
return nil, err
}
if a.blocks != nil {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after add failed", "error", rerr)
}
}
// A new rule changes the verdict for the whole subtree it covers, and an
// allow rule can rescue a name many other lists blocked, so the honest
// thing is to drop the affected answers rather than guess.
a.invalidateBlocklistCache()
return map[string]any{"ok": true, "name": name, "count": len(entries)}, nil
}
// deleteDomain removes a rule and persists the change.
func (a *App) deleteDomain(_ context.Context, name, action, list string) error {
target := strings.ToLower(strings.TrimSpace(name))
removed := 0
err := a.mutateConfig(func(c *config.Config) error {
strip := func(entries []string) []string {
out := entries[:0]
for _, e := range entries {
normal := strings.ToLower(strings.TrimSpace(e))
normal = strings.TrimPrefix(strings.TrimPrefix(normal, "*."), "**.")
normal = strings.TrimSuffix(normal, ".")
if normal == target {
removed++
continue
}
out = append(out, e)
}
return out
}
c.Block.BlockEntries = strip(c.Block.BlockEntries)
c.Block.AllowEntries = strip(c.Block.AllowEntries)
// A file-backed list is edited by editing the file, so the API only
// reports that rather than pretending it removed something.
if list != "" && removed == 0 {
return fmt.Errorf("%q comes from list %q; edit that list instead", name, list)
}
if removed == 0 {
return fmt.Errorf("no inline rule matches %q", name)
}
return nil
})
if err != nil {
return err
}
if a.blocks != nil {
if _, rerr := a.blocks.RefreshAll(context.Background()); rerr != nil {
a.log.Warn("refresh after delete failed", "error", rerr)
}
}
a.invalidateBlocklistCache()
return nil
}
// getACL returns the ACL groups.
func (a *App) getACL() any {
groups := a.engine.GroupStats()
return map[string]any{
"groups": groups,
"default_policy": a.cfg.Get().ACL.DefaultPolicy,
"default_rate_limit": a.cfg.Get().ACL.DefaultRateLimit,
}
}
// putACL validates and persists new ACL groups.
func (a *App) putACL(_ context.Context, groups []config.Group, def string) (int64, error) {
err := a.mutateConfig(func(c *config.Config) error {
c.ACL.Groups = groups
if def != "" {
c.ACL.DefaultPolicy = def
}
return nil
})
if err != nil {
return 0, err
}
// An allow_domains or deny_domains change alters the verdict for names
// already in the cache, so the cached answers are no longer trustworthy.
if a.cache != nil {
a.cache.Flush()
}
return a.cfg.Get().Version, nil
}
// getUpstreams returns the pool state.
func (a *App) getUpstreams() any {
cfg := a.cfg.Get()
servers := make([]resolver.Stats, 0, len(a.pool.All()))
for _, s := range a.pool.Stats() {
servers = append(servers, s)
}
return map[string]any{
"policy": cfg.Upstream.Policy,
"servers": servers,
"bootstraps": cfg.Upstream.Bootstraps,
"healthcheck_interval": cfg.Upstream.HealthCheck.D().Seconds(),
"max_attempts": cfg.Upstream.MaxAttempts,
"breaker_threshold": cfg.Upstream.BreakerThreshold,
"breaker_cooldown": cfg.Upstream.BreakerCooldown.D().Seconds(),
}
}
// putUpstream validates and persists a new upstream config.
func (a *App) putUpstream(_ context.Context, up config.Upstream) (int64, error) {
err := a.mutateConfig(func(c *config.Config) error {
c.Upstream = up
return nil
})
if err != nil {
return 0, err
}
// The pool rebinds itself on the config swap; do it now so the UI sees
// the new servers immediately rather than after the next reload.
a.pool.Rebind(up)
return a.cfg.Get().Version, nil
}
// searchLog pages the query log.
func (a *App) searchLog(_ context.Context, q api.LogQuery) (any, error) {
if a.logger == nil {
return map[string]any{"items": []any{}, "total": 0, "offset": 0}, nil
}
page, err := a.logger.Search(qlog.Query{
Limit: q.Limit, Offset: q.Offset, Text: q.Text, Action: q.Action,
Client: q.Client, RCode: q.RCode, BlockedOnly: q.BlockedOnly, Since: q.Since,
})
if err != nil {
return nil, err
}
out := make([]api.LogEntry, 0, len(page.Items))
for _, e := range page.Items {
out = append(out, toLogEntry(e))
}
return map[string]any{
"items": out,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
"has_more": len(out) == page.Limit,
}, nil
}
func toLogEntry(e qlog.Entry) api.LogEntry {
return api.LogEntry{
ID: e.ID, TS: e.TS, Client: e.Client, Group: e.Group, Name: e.Name,
QType: e.QType, QTypeStr: e.QTypeStr, RCode: e.RCode,
RCodeStr: e.RCodeName(), Action: string(e.Action), Response: e.Response,
Duration: e.Duration, Upstream: e.Upstream, List: e.List, Protocol: e.Protocol,
Blocked: e.Blocked,
}
}
// logStats is the live stats frame pushed over SSE.
func (a *App) logStats() any {
t := metrics.TotalsNow()
var hitRate float64
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
var blockedPct float64
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries)
}
return map[string]any{
"qps": a.series.qps(),
"blocked_pct": blockedPct,
"cache_hit_rate": hitRate,
"queries_total": t.Queries,
"blocked_total": t.Blocked,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"uptime_s": int64(time.Since(a.startedAt).Seconds()),
}
}
// subscribeLog streams new entries to one SSE client.
func (a *App) subscribeLog() (<-chan api.LogEntry, func()) {
if a.logger == nil {
ch := make(chan api.LogEntry)
close(ch)
return ch, func() {}
}
src, cancel := a.logger.Subscribe()
out := make(chan api.LogEntry, 256)
done := make(chan struct{})
go func() {
defer close(out)
for {
select {
case <-done:
return
case e, ok := <-src:
if !ok {
return
}
select {
case out <- toLogEntry(e):
case <-done:
return
}
}
}
}()
return out, func() {
close(done)
cancel()
}
}
// clearLog empties the query log.
func (a *App) clearLog() error {
if a.logger == nil {
return nil
}
return a.logger.Clear()
}
// cacheStats returns the cache snapshot.
func (a *App) cacheStats() any {
if a.cache == nil {
return map[string]any{"enabled": false}
}
st := a.cache.Stats()
return map[string]any{
"enabled": st.Enabled,
"size": st.Size,
"entries": st.Entries,
"shards": st.Shards,
"min_ttl_ms": st.MinTTLMS,
"max_ttl_ms": st.MaxTTLMS,
"serve_stale_ms": st.ServeStaleM,
"negative_ttl_ms": st.NegativeMS,
"hit_rate": st.HitRate,
"hits": st.Hits,
"misses": st.Misses,
"stale_hits": st.StaleHits,
"evictions": st.Evictions,
"bytes": st.Bytes,
"bytes_human": st.MemoryHuman,
"persisted": st.Persist,
}
}
// cacheTop returns the hottest cached names.
func (a *App) cacheTop(limit int) any {
if a.cache == nil {
return []any{}
}
entries := a.cache.TopEntries(limit)
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
out = append(out, map[string]any{
"name": e.Name,
"qtype": e.Type,
"qtype_name": dns.Type(e.Type).String(),
"hits": e.Hits,
"bytes": e.Bytes,
})
}
return out
}
// flushCache empties the cache.
func (a *App) flushCache() int64 {
if a.cache == nil {
return 0
}
st := a.cache.Stats()
a.cache.Flush()
return int64(st.Entries)
}
// flush persists buffered state.
func (a *App) flush() {
if a.logger != nil {
a.logger.Flush()
}
a.snapshotCache()
}
// seriesStore keeps the rolling counters the dashboard charts read.
type seriesStore struct {
mu sync.Mutex
queries *metrics.Ring
blocked *metrics.Ring
errors *metrics.Ring
cacheHit *metrics.Ring
// The previous totals, used to derive per-bucket deltas.
lastQueries uint64
lastBlocked uint64
lastErrors uint64
lastHits uint64
lastAt time.Time
qpsValue float64
}
func newSeriesStore() *seriesStore {
return &seriesStore{
queries: metrics.NewRing(300, 5*time.Second),
blocked: metrics.NewRing(300, 5*time.Second),
errors: metrics.NewRing(300, 5*time.Second),
cacheHit: metrics.NewRing(300, 5*time.Second),
lastAt: time.Now(),
}
}
// sample folds the delta since the last sample into the rings. Sampling on a
// timer rather than per query keeps the query path free of any extra work and
// is accurate enough for a chart.
func (s *seriesStore) sample() {
t := metrics.TotalsNow()
s.mu.Lock()
now := time.Now()
dt := now.Sub(s.lastAt).Seconds()
if dt > 0 {
s.qpsValue = float64(t.Queries-s.lastQueries) / dt
}
// Ring buckets are 5s wide; scale the interval delta accordingly.
scale := dt / 5
if scale < 0 {
scale = 0
}
dq := int64(float64(t.Queries-s.lastQueries) * scale)
db := int64(float64(t.Blocked-s.lastBlocked) * scale)
de := int64(float64(t.Errors-s.lastErrors) * scale)
dc := int64(float64(t.Hits-s.lastHits) * scale)
if dq > 0 {
s.queries.Add(dq)
}
if db > 0 {
s.blocked.Add(db)
}
if de > 0 {
s.errors.Add(de)
}
if dc > 0 {
s.cacheHit.Add(dc)
}
s.lastQueries, s.lastBlocked, s.lastErrors, s.lastHits = t.Queries, t.Blocked, t.Errors, t.Hits
s.lastAt = now
s.mu.Unlock()
}
func (s *seriesStore) qps() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.qpsValue
}
// dashboard builds the aggregated payload.
func (a *App) dashboard(_ context.Context, rangeKey string) (any, error) {
spec, ok := ranges[rangeKey]
if !ok {
spec = ranges["1h"]
}
t := metrics.TotalsNow()
hitRate := 0.0
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
blockedPct := 0.0
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries) * 100
}
// Build the series by walking the rings oldest-first and dropping anything
// older than the requested range.
var series []map[string]any
now := time.Now()
qts, qv := a.series.queries.Snapshot()
_, bv := a.series.blocked.Snapshot()
_, ev := a.series.errors.Snapshot()
_, cv := a.series.cacheHit.Snapshot()
series = make([]map[string]any, 0, len(qts))
for i := range qts {
age := now.Sub(time.Unix(qts[i], 0))
if age > spec.duration {
break
}
series = append(series, map[string]any{
"t": qts[i],
"queries": qv[i],
"blocked": bv[i],
"errors": ev[i],
"cache_hits": cv[i],
})
}
topBlocked := []any{}
topQueried := []any{}
if a.logger != nil {
for _, d := range a.logger.TopBlocked(15) {
topBlocked = append(topBlocked, map[string]any{
"domain": d.Name, "count": d.Count, "list": "", "qtype": d.QType,
})
}
for _, d := range a.logger.TopQueried(15) {
topQueried = append(topQueried, map[string]any{
"domain": d.Name, "count": d.Count, "qtype": d.QType, "blocked": d.Blocked > 0,
})
}
}
problems := a.Degraded()
status := "ok"
if len(problems) > 0 {
status = "degraded"
}
out := map[string]any{
"health": status,
"degraded_reason": strings.Join(problems, "; "),
"version": a.ver,
"node_id": a.clusterNodeID(),
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()),
"queries_total": t.Queries,
"queries_per_sec": a.series.qps(),
"cache_hit_rate": hitRate,
"blocked_total": t.Blocked,
"blocked_pct": blockedPct,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"series": series,
"top_blocked": topBlocked,
"top_queried": topQueried,
"upstreams": a.pool.Stats(),
"listeners": a.dns.Listeners(),
"transports": a.dns.ByTransport(),
"blocklist_rules": a.blocklistRuleCount(),
"cache_entries": a.cacheEntries(),
"config_version": a.cfg.Get().Version,
"generation": a.cfg.Generation(),
}
if a.logger != nil {
recent := a.logger.Recent(20)
items := make([]api.LogEntry, 0, len(recent))
for _, e := range recent {
items = append(items, toLogEntry(e))
}
out["recent"] = items
}
return out, nil
}
func (a *App) clusterNodeID() string {
if a.cl != nil {
return a.cl.NodeID()
}
return ""
}
func (a *App) blocklistRuleCount() int {
if a.blocks == nil {
return 0
}
if st := a.blocks.State(); st != nil && st.Set != nil {
return st.Set.Len()
}
return 0
}
func (a *App) cacheEntries() int {
if a.cache == nil {
return 0
}
return a.cache.Stats().Entries
}
// rangeSpec is one dashboard time range.
type rangeSpec struct {
duration time.Duration
buckets int
}
var ranges = map[string]rangeSpec{
"15m": {duration: 15 * time.Minute, buckets: 45},
"1h": {duration: time.Hour, buckets: 60},
"6h": {duration: 6 * time.Hour, buckets: 72},
"24h": {duration: 24 * time.Hour, buckets: 96},
}
+794
View File
@@ -0,0 +1,794 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
// to the upstream and the answer is cached.
func TestResolveUpstream(t *testing.T) {
h := newHarness(t, nil)
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) == 0 {
t.Fatal("no answer records")
}
if got := res.Answer[0].(*dns.A).A.String(); got != "203.0.113.7" {
t.Errorf("answer = %s, want 203.0.113.7", got)
}
if h.up.hitCount("www.example.test.") == 0 {
t.Error("upstream was never asked")
}
// Second query must come from cache: the upstream counter must not move.
before := h.up.hitCount("www.example.test.")
h.query("www.example.test", dns.TypeA)
if after := h.up.hitCount("www.example.test."); after != before {
t.Errorf("upstream hit count moved from %d to %d: the cache did not serve the repeat query", before, after)
}
}
// TestBlockedNames covers every blocklist source shape: hosts-file entries
// (apex plus subdomains), RPZ wildcards and inline rules.
func TestBlockedNames(t *testing.T) {
h := newHarness(t, nil)
cases := []string{
"ads.example.com", // hosts file
"deep.ads.example.com", // hosts file, subdomain
"tracker.example.net",
"inline-blocked.example.com",
"anything.inline-sub.example.com",
}
for _, name := range cases {
res := h.query(name, dns.TypeA)
if res.Rcode != dns.RcodeNameError {
t.Errorf("%s: rcode = %s, want NXDOMAIN", name, dns.RcodeToString[res.Rcode])
}
}
// A blocked name must never reach the upstream.
if n := h.up.hitCount("ads.example.com."); n != 0 {
t.Errorf("blocked name reached the upstream %d times", n)
}
}
// TestAllowOverridesBlock checks that an inline allow entry beats a list rule.
func TestAllowOverridesBlock(t *testing.T) {
h := newHarness(t, nil)
res := h.query("allow.example.com", dns.TypeA)
// The fake upstream only knows *.test., so an allowed-but-unresolvable name
// must come back as NXDOMAIN *from upstream*, which means it got past the
// blocklist. Assert on the upstream counter instead of the rcode.
if h.up.hitCount("allow.example.com.") == 0 {
t.Error("allowed name did not reach the upstream, so the allow rule lost to a block rule")
}
_ = res
}
// TestLocalZone covers the local master file: exact, wildcard and NODATA.
func TestLocalZone(t *testing.T) {
h := newHarness(t, nil)
res := h.query("ns.local", dns.TypeA)
if len(res.Answer) != 1 {
t.Fatalf("want 1 answer for ns.local, got %d", len(res.Answer))
}
if got := res.Answer[0].(*dns.A).A.String(); got != "192.168.1.2" {
t.Errorf("ns.local = %s, want 192.168.1.2", got)
}
if !res.Authoritative {
t.Error("local zone answer should be authoritative")
}
// Wildcard covers a name one label below the wildcard's parent.
w := h.query("anything.wild.local", dns.TypeA)
if len(w.Answer) == 0 || w.Answer[0].(*dns.A).A.String() != "192.168.1.50" {
t.Errorf("wildcard answer wrong: %v", w.Answer)
}
// NODATA: the name exists but has no A record.
n := h.query("deep.svc.local", dns.TypeAAAA)
if n.Rcode != dns.RcodeSuccess {
t.Errorf("NODATA rcode = %s, want NOERROR", dns.RcodeToString[n.Rcode])
}
if len(n.Answer) != 0 {
t.Errorf("NODATA returned %d answers", len(n.Answer))
}
// NXDOMAIN inside a zone we are authoritative for.
x := h.query("nope.local", dns.TypeA)
if x.Rcode != dns.RcodeNameError {
t.Errorf("in-zone NXDOMAIN rcode = %s", dns.RcodeToString[x.Rcode])
}
}
// TestACLDenyGroup checks that a deny group refuses before anything else,
// including a name the local zone could answer.
func TestACLDenyGroup(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "deny-all",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "deny",
Comment: "test",
}}
})
res := h.query("www.example.test", dns.TypeA)
if res.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[res.Rcode])
}
if h.up.hitCount("www.example.test.") != 0 {
t.Error("a denied client still reached the upstream")
}
}
// TestACLAllowDomains checks a group's allow_domains acts as a filter.
func TestACLAllowDomains(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "only-test",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
AllowDomains: []string{"*.test"},
}}
})
// Inside the allow list: resolves.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("allowed domain rcode = %s", dns.RcodeToString[r.Rcode])
}
// Outside it: blocked by the group.
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("domain outside allow_domains rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
}
// TestACLSkipBlocklist checks a group can opt out of blocklist filtering.
func TestACLSkipBlocklist(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.ACL.Groups = []config.Group{{
Name: "trusted",
Match: config.Match{CIDRs: []string{"127.0.0.0/8"}},
Policy: "allow",
SkipBlocklist: true,
}}
})
h.query("ads.example.com", dns.TypeA)
if h.up.hitCount("ads.example.com.") == 0 {
t.Error("skip_blocklist group still blocked the name")
}
}
// TestBlockActions covers the non-default blocklist responses.
func TestBlockActions(t *testing.T) {
t.Run("zero", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "zero"
c.Block.ResponseIP = "0.0.0.0"
})
res := h.query("ads.example.com", dns.TypeA)
if res.Rcode != dns.RcodeSuccess {
t.Fatalf("rcode = %s, want NOERROR", dns.RcodeToString[res.Rcode])
}
if len(res.Answer) != 1 || res.Answer[0].(*dns.A).A.String() != "0.0.0.0" {
t.Errorf("sinkhole answer = %v", res.Answer)
}
// AAAA for the same name must get the v6 sinkhole, not the v4 one.
res6 := h.query("ads.example.com", dns.TypeAAAA)
if len(res6.Answer) != 1 || res6.Answer[0].(*dns.AAAA).AAAA.String() != "::" {
t.Errorf("v6 sinkhole answer = %v", res6.Answer)
}
})
t.Run("refused", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.Block.DefaultAction = "refused" })
if r := h.query("ads.example.com", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
})
t.Run("redirect", func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.Block.DefaultAction = "redirect"
c.Block.RedirectName = "blockpage.local"
})
res := h.query("ads.example.com", dns.TypeA)
if len(res.Answer) == 0 {
t.Fatal("redirect produced no answer")
}
cname, ok := res.Answer[0].(*dns.CNAME)
if !ok {
t.Fatalf("first answer is %T, want CNAME", res.Answer[0])
}
if cname.Target != "blockpage.local." {
t.Errorf("cname target = %s", cname.Target)
}
})
}
// TestMalformedQueries covers the input validation path.
func TestMalformedQueries(t *testing.T) {
h := newHarness(t, nil)
// Zero questions: the server must answer FORMERR, not crash or hang.
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = nil
c := &dns.Client{Timeout: 3 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
t.Fatalf("exchange: %v", err)
}
if res.Rcode != dns.RcodeFormatError {
t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode])
}
}
// TestTCPSameAnswer checks TCP returns the same answer as UDP, and that a large
// UDP answer is truncated rather than dropped.
func TestTCPSameAnswer(t *testing.T) {
h := newHarness(t, nil)
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
tcp := &dns.Client{Net: "tcp", Timeout: 5 * time.Second}
res, _, err := tcp.Exchange(m, h.addr)
if err != nil {
t.Fatalf("tcp exchange: %v", err)
}
if res.Rcode != dns.RcodeSuccess || len(res.Answer) == 0 {
t.Errorf("tcp answer wrong: rcode=%s answers=%d", dns.RcodeToString[res.Rcode], len(res.Answer))
}
}
// TestAPIAuth checks the admin API refuses unauthenticated reads and accepts
// the configured key.
func TestAPIAuth(t *testing.T) {
h := newHarness(t, nil)
// No key: 401.
res, err := httpGet("http://"+h.api+"/api/whoami", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("unauthenticated /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", "wrong-key")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("bad key /whoami = %d, want 401", res.status)
}
res, err = httpGet("http://"+h.api+"/api/whoami", h.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("good key /whoami = %d, want 200", res.status)
}
var who struct {
Name string `json:"name"`
CanWrite bool `json:"can_write"`
}
if err := json.Unmarshal(res.body, &who); err != nil {
t.Fatal(err)
}
if who.Name != "rw-1" || !who.CanWrite {
t.Errorf("whoami = %+v", who)
}
// Health is deliberately unauthenticated for probes.
res, err = httpGet("http://"+h.api+"/api/healthz", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("/healthz = %d, want 200", res.status)
}
}
// TestReadOnlyKeyCannotWrite checks a read-only key is refused on mutations.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth.ReadOnlyAPIKeys = []string{"ro-key"}
})
res, err := httpRequest("POST", "http://"+h.api+"/api/cache/flush", "ro-key", "")
if err != nil {
t.Fatal(err)
}
if res.status != 403 {
t.Errorf("read-only flush = %d, want 403", res.status)
}
res, err = httpRequest("POST", "http://"+h.api+"/api/cache/flush", h.key, "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("read-write flush = %d, want 200", res.status)
}
}
// TestDomainAddDeleteRoundTrip is the operator's core loop: add a rule, see it
// take effect on a live query, then remove it.
func TestDomainAddDeleteRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// The name lives under .test. so the fake upstream resolves it: that way
// NOERROR unambiguously means "not blocked" and NXDOMAIN unambiguously
// means "the blocklist answered", with no third possibility to confuse the
// assertion.
const name = "newly-blocked.example.test"
before := h.up.hitCount(dns.Fqdn(name))
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Fatalf("precondition: rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
if h.up.hitCount(dns.Fqdn(name)) == before {
t.Error("precondition: the upstream was not consulted")
}
code, body := h.do("POST", "/api/domains", `{"name":"`+name+`","action":"block"}`)
if code != 200 {
t.Fatalf("add domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after add, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
// The rule must survive a cache flush, i.e. it is really in the config and
// not just a transient matcher state.
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Fatalf("flush = %d", code)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Errorf("after flush, rcode = %s, want NXDOMAIN", dns.RcodeToString[r.Rcode])
}
code, body = h.do("DELETE", "/api/domains/"+name, "")
if code != 200 {
t.Fatalf("delete domain = %d: %s", code, body)
}
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after delete, rcode = %s, want NOERROR", dns.RcodeToString[r.Rcode])
}
}
// TestDomainAddInvalid checks the API rejects a bad payload with 400 rather
// than writing a broken rule.
func TestDomainAddInvalid(t *testing.T) {
h := newHarness(t, nil)
code, _ := h.do("POST", "/api/domains", `{"name":"","action":"block"}`)
if code != 400 {
t.Errorf("empty name = %d, want 400", code)
}
code, _ = h.do("POST", "/api/domains", `{"name":"x.example.com","action":"nonsense"}`)
if code != 400 {
t.Errorf("bad action = %d, want 400", code)
}
}
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
// saved group is enforced immediately.
func TestACLUpdateRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// Write a deny group through the API.
body := `{"groups":[{"name":"nobody","match":{"cidrs":["127.0.0.0/8"]},"policy":"deny","comment":"test"}],"default_policy":"allow"}`
code, out := h.do("PUT", "/api/clients", body)
if code != 200 {
t.Fatalf("put clients = %d: %s", code, out)
}
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeRefused {
t.Errorf("after save, rcode = %s, want REFUSED", dns.RcodeToString[r.Rcode])
}
// Read it back.
code, out = h.get("/api/clients")
if code != 200 {
t.Fatalf("get clients = %d", code)
}
if !strings.Contains(string(out), "nobody") {
t.Error("saved group is missing from the read-back")
}
// An invalid group must be rejected with 400 and not applied.
bad := `{"groups":[{"name":"x","match":{"cidrs":["not-a-cidr"]},"policy":"deny"}],"default_policy":"allow"}`
code, _ = h.do("PUT", "/api/clients", bad)
if code != 400 {
t.Errorf("bad CIDR = %d, want 400", code)
}
}
// TestUpstreamUpdate checks the upstreams editor round-trips and rebinds the
// pool without a restart.
func TestUpstreamUpdate(t *testing.T) {
h := newHarness(t, nil)
body := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr)
code, out := h.do("PUT", "/api/upstreams", body)
if code != 200 {
t.Fatalf("put upstreams = %d: %s", code, out)
}
code, out = h.get("/api/upstreams")
if code != 200 {
t.Fatalf("get upstreams = %d", code)
}
if !strings.Contains(string(out), "failover") {
t.Error("policy did not persist")
}
// Still resolves after the rebind.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after upstream update, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Invalid config rejected.
code, _ = h.do("PUT", "/api/upstreams", `{"policy":"nonsense","servers":[]}`)
if code != 400 {
t.Errorf("bad policy = %d, want 400", code)
}
}
// TestSettingsValidateAndSave covers the config editor: validate, save, and
// confirm the file on disk changed.
func TestSettingsValidateAndSave(t *testing.T) {
h := newHarness(t, nil)
code, out := h.get("/api/settings")
if code != 200 {
t.Fatalf("get settings = %d", code)
}
var s struct {
ConfigYAML string `json:"config_yaml"`
Path string `json:"path"`
Writable bool `json:"writable"`
}
if err := json.Unmarshal(out, &s); err != nil {
t.Fatal(err)
}
if !s.Writable {
t.Error("config file should be writable in the test harness")
}
// A valid edit round-trips.
edited := strings.Replace(s.ConfigYAML, "max_concurrent: 1024", "max_concurrent: 2048", 1)
if edited == s.ConfigYAML {
t.Fatal("test could not find the setting it wanted to edit")
}
payload, _ := json.Marshal(map[string]string{"config_yaml": edited})
code, out = h.do("POST", "/api/settings/validate", string(payload))
if code != 200 {
t.Fatalf("validate = %d: %s", code, out)
}
var v struct {
OK bool `json:"ok"`
Errors []struct {
Field string `json:"field"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(out, &v); err != nil {
t.Fatal(err)
}
if !v.OK {
t.Errorf("valid config reported errors: %+v", v.Errors)
}
code, out = h.do("PUT", "/api/settings", string(payload))
if code != 200 {
t.Fatalf("save settings = %d: %s", code, out)
}
// The file on disk must now contain the edit.
onDisk := readFileString(t, s.Path)
if !strings.Contains(onDisk, "max_concurrent: 2048") {
t.Error("config file on disk was not updated")
}
// An invalid document must be refused and the file left alone.
badPayload, _ := json.Marshal(map[string]string{"config_yaml": "server:\n max_concurrent: -5\n"})
code, _ = h.do("PUT", "/api/settings", string(badPayload))
if code != 400 {
t.Errorf("invalid save = %d, want 400", code)
}
if readFileString(t, s.Path) != onDisk {
t.Error("a rejected config write still modified the file")
}
}
// TestBlocklistToggle checks a disabled list stops applying and its cached
// answers are dropped.
func TestBlocklistToggle(t *testing.T) {
h := newHarness(t, nil)
const name = "blocked.example.test"
if r := h.query(name, dns.TypeA); r.Rcode != dns.RcodeNameError {
t.Fatal("precondition: name should be blocked")
}
code, out := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":false}`)
if code != 200 {
t.Fatalf("toggle = %d: %s", code, out)
}
// The recompile runs in the background, so poll rather than assume.
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeSuccess
})
// Re-enabling must block it again: a one-way toggle would be a nasty
// surprise.
if code, _ := h.do("POST", "/api/blocklists/toggle", `{"name":"test-hosts","enabled":true}`); code != 200 {
t.Fatalf("re-enable = %d", code)
}
waitFor(t, 5*time.Second, func() bool {
return h.query(name, dns.TypeA).Rcode == dns.RcodeNameError
})
}
// TestDashboardAndStatus checks the aggregate endpoints return the fields the
// UI reads, so a frontend change cannot silently drift from the API.
func TestDashboardAndStatus(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
code, out := h.get("/api/dashboard?range=1h")
if code != 200 {
t.Fatalf("dashboard = %d", code)
}
var d struct {
Health string `json:"health"`
Queries uint64 `json:"queries_total"`
Blocked uint64 `json:"blocked_total"`
HitRate any `json:"cache_hit_rate"`
Series []any `json:"series"`
TopBlocked []struct {
Domain string `json:"domain"`
Count uint64 `json:"count"`
} `json:"top_blocked"`
Upstreams []struct {
Addr string `json:"addr"`
Health string `json:"health"`
} `json:"upstreams"`
}
if err := json.Unmarshal(out, &d); err != nil {
t.Fatal(err)
}
if d.Health != "ok" {
t.Errorf("health = %q, want ok", d.Health)
}
if d.Queries == 0 {
t.Error("queries_total is zero after two queries")
}
if d.Blocked == 0 {
t.Error("blocked_total is zero after a blocked query")
}
if len(d.Upstreams) == 0 {
t.Error("upstreams empty in dashboard")
}
if len(d.Series) == 0 {
t.Error("series empty in dashboard")
}
_ = d.HitRate
_ = d.TopBlocked
code, out = h.get("/api/status")
if code != 200 {
t.Fatalf("status = %d", code)
}
if !strings.Contains(string(out), "listeners") {
t.Error("status has no listeners section")
}
}
// TestLogsAndClear covers the query log endpoint and the clear action.
func TestLogsAndClear(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
h.query("ads.example.com", dns.TypeA)
waitFor(t, 3*time.Second, func() bool {
code, out := h.get("/api/logs?limit=50")
if code != 200 {
return false
}
return strings.Contains(string(out), "example.test")
})
code, out := h.get("/api/logs?q=ads.example.com&blocked=1")
if code != 200 {
t.Fatalf("logs = %d", code)
}
if !strings.Contains(string(out), "ads.example.com") {
t.Error("filtered log did not include the blocked query")
}
if code, _ := h.do("POST", "/api/logs/clear", ""); code != 200 {
t.Errorf("clear logs = %d, want 200", code)
}
}
// TestCacheEndpoints covers the cache stats/top/flush trio.
func TestCacheEndpoints(t *testing.T) {
h := newHarness(t, nil)
h.query("www.example.test", dns.TypeA)
code, out := h.get("/api/cache")
if code != 200 {
t.Fatalf("cache = %d", code)
}
var c struct {
Entries int `json:"entries"`
Shards int `json:"shards"`
HitRate float64 `json:"hit_rate"`
}
if err := json.Unmarshal(out, &c); err != nil {
t.Fatal(err)
}
if c.Shards <= 0 {
t.Error("shards is zero")
}
code, out = h.get("/api/cache/top?limit=10")
if code != 200 {
t.Fatalf("cache top = %d", code)
}
if !strings.Contains(string(out), "example.test") {
t.Error("cache top does not list the queried name")
}
if code, _ := h.do("POST", "/api/cache/flush", ""); code != 200 {
t.Errorf("flush = %d, want 200", code)
}
}
// TestReloadFromDisk checks SIGHUP-style reload applies a hand-edited file and
// reports a broken one without dropping the running config.
func TestReloadFromDisk(t *testing.T) {
h := newHarness(t, nil)
// Break the file, then reload: must fail and keep serving.
broken := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: not-a-number", 1)
if broken == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to break")
}
writeFileString(t, h.app.cfg.Path(), broken)
if err := h.app.Reload(); err == nil {
t.Fatal("reload of a broken config should fail")
}
if h.app.cfg.LastError() == "" {
t.Error("a failed reload should record the error for the health endpoint")
}
// The server keeps working with the previous config.
if r := h.query("www.example.test", dns.TypeA); r.Rcode != dns.RcodeSuccess {
t.Errorf("after a failed reload, rcode = %s", dns.RcodeToString[r.Rcode])
}
// Fix the file and reload: should succeed and clear the error. The whole
// document is rewritten from the harness's own config, changing only the
// broken value, so the reload is testing a realistic edit rather than a
// minimal stub.
fixed := strings.Replace(h.OriginalConfig(), "max_concurrent: 1024", "max_concurrent: 512", 1)
if fixed == h.OriginalConfig() {
t.Fatal("test could not find the setting it wanted to edit")
}
writeFileString(t, h.app.cfg.Path(), fixed)
if err := h.app.Reload(); err != nil {
t.Fatalf("reload of a fixed config failed: %v", err)
}
if h.app.cfg.LastError() != "" {
t.Error("a successful reload should clear the recorded error")
}
}
// TestLogStream checks the SSE endpoint emits a ready frame and at least one
// entry frame, and that it terminates when the client disconnects.
func TestLogStream(t *testing.T) {
h := newHarness(t, nil)
req, err := httpNewRequest("GET", "http://"+h.api+"/api/logs/stream", h.key)
if err != nil {
t.Fatal(err)
}
// A streaming client must not time out while idle, so give it a generous
// deadline; the test closes the body when it has seen what it needs.
streamClient := &http.Client{Timeout: 20 * time.Second}
resp, err := streamClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("stream = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("content-type = %q, want text/event-stream", ct)
}
if resp.Header.Get("X-Accel-Buffering") != "no" {
t.Error("missing X-Accel-Buffering: no, a proxy would buffer the stream")
}
// Generate a query and read frames until we see an entry.
go func() {
time.Sleep(150 * time.Millisecond)
h.query("www.example.test", dns.TypeA)
}()
buf := make([]byte, 4096)
seen := map[string]bool{}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := resp.Body.Read(buf)
if err != nil {
break
}
chunk := string(buf[:n])
for _, ev := range []string{"event: ready", "event: entry", "event: stats"} {
if strings.Contains(chunk, ev) {
seen[ev] = true
}
}
if seen["event: ready"] && seen["event: entry"] {
break
}
}
if !seen["event: ready"] {
t.Error("no ready event")
}
if !seen["event: entry"] {
t.Error("no entry event for a query issued while streaming")
}
}
// TestMetricsEndpoint checks Prometheus output is served and protected.
func TestMetricsEndpoint(t *testing.T) {
h := newHarness(t, nil)
res, err := httpGet("http://"+h.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("unprotected /metrics = %d, want 200", res.status)
}
if !strings.Contains(string(res.body), "fancydns_queries_total") {
t.Error("metrics output has no fancydns_queries_total")
}
// With protection on, an unauthenticated scrape must fail.
h2 := newHarness(t, func(c *config.Config) { c.Metrics.Protect = true })
res, err = httpGet("http://"+h2.api+"/api/metrics", "")
if err != nil {
t.Fatal(err)
}
if res.status != 401 {
t.Errorf("protected /metrics without a key = %d, want 401", res.status)
}
res, err = httpGet("http://"+h2.api+"/api/metrics", h2.key)
if err != nil {
t.Fatal(err)
}
if res.status != 200 {
t.Errorf("protected /metrics with a key = %d, want 200", res.status)
}
}
// TestConcurrentQueries hammers the server from many goroutines to catch races
// and to make sure the shared state is actually safe. Run with -race.
func TestConcurrentQueries(t *testing.T) {
h := newHarness(t, nil)
const workers, each = 16, 25
done := make(chan error, workers)
for w := 0; w < workers; w++ {
go func(w int) {
m := new(dns.Msg)
m.SetQuestion("www.example.test.", dns.TypeA)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
for i := 0; i < each; i++ {
if _, _, err := c.Exchange(m, h.addr); err != nil {
done <- err
return
}
}
done <- nil
}(w)
}
for w := 0; w < workers; w++ {
if err := <-done; err != nil {
t.Fatalf("concurrent query failed: %v", err)
}
}
// Also exercise the API concurrently, since it reads the same state.
apiDone := make(chan error, workers)
for w := 0; w < workers; w++ {
go func() {
res, err := httpGet("http://"+h.api+"/api/dashboard", h.key)
if err != nil {
apiDone <- err
return
}
if res.status != 200 {
apiDone <- fmt.Errorf("dashboard = %d", res.status)
return
}
apiDone <- nil
}()
}
for w := 0; w < workers; w++ {
if err := <-apiDone; err != nil {
t.Errorf("concurrent api call failed: %v", err)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package app
import (
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
// httpResult is a small wrapper so the tests read cleanly.
type httpResult struct {
status int
header http.Header
body []byte
}
var testClient = &http.Client{Timeout: 10 * time.Second}
func httpNewRequest(method, url, key string) (*http.Request, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
if key != "" {
req.Header.Set("X-API-Key", key)
}
return req, nil
}
func httpGet(url, key string) (httpResult, error) {
req, err := httpNewRequest("GET", url, key)
if err != nil {
return httpResult{}, err
}
return httpDo(req)
}
func httpRequest(method, url, key, body string) (httpResult, error) {
var rdr io.Reader
if body != "" {
rdr = bytesReader(body)
}
req, err := http.NewRequest(method, url, rdr)
if err != nil {
return httpResult{}, err
}
req.Header.Set("X-API-Key", key)
req.Header.Set("Content-Type", "application/json")
return httpDo(req)
}
func httpDo(req *http.Request) (httpResult, error) {
res, err := testClient.Do(req)
if err != nil {
return httpResult{}, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return httpResult{status: res.StatusCode, header: res.Header, body: b}, nil
}
func bytesReader(s string) io.Reader { return strings.NewReader(s) }
// waitFor polls cond until it holds or the timeout expires. Used where an
// action is intentionally asynchronous (blocklist refresh runs in a goroutine).
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("condition not met within %s", timeout)
}
func readFileString(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func writeFileString(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+647
View File
@@ -0,0 +1,647 @@
package blocklist
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Source is a compiled list plus the bookkeeping the UI shows.
type Source struct {
Name string
Format Format
Meta SourceMeta
Stats ParseStats
// Digest identifies the compiled content, so instances can tell whether
// they hold the same rules without shipping the rules around.
Digest string
}
// State is the compiled blocklist plus its per-source detail.
type State struct {
Set *Set
Sources []Source
// Duration is how long the last full compile took.
Duration time.Duration
// Errors are per-source failures from the last refresh. A source that
// fails keeps its previous content, so the errors are warnings, not
// fatal.
Errors map[string]string
// Digest covers every source, in order.
Digest string
}
// Manager owns the blocklist lifecycle: fetching sources, compiling them, and
// atomically swapping the compiled Set into the holder.
type Manager struct {
cfg *config.Store
log *slog.Logger
hold *Holder
http *http.Client
stats *StateHolder
mu sync.Mutex
running map[string]bool // in-flight refreshes, by source name
// trigger lets the cluster ask one instance to refresh (leader election).
trigger chan string
stop chan struct{}
wg sync.WaitGroup
}
// StateHolder is a swappable State for the API.
type StateHolder struct{ cur atomic.Pointer[State] }
// NewStateHolder wraps an initial state.
func NewStateHolder(s *State) *StateHolder {
h := &StateHolder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current state.
func (h *StateHolder) Get() *State { return h.cur.Load() }
// ManagerOptions configures a Manager.
type ManagerOptions struct {
// Config is the hot-reloadable config store.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Client is the HTTP client used for list fetches; nil builds a default.
Client *http.Client
}
// NewManager builds a Manager.
func NewManager(opt ManagerOptions) *Manager {
log := opt.Logger
if log == nil {
log = slog.Default()
}
client := opt.Client
if client == nil {
client = &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
},
}
}
m := &Manager{
cfg: opt.Config,
log: log.With("component", "blocklist"),
hold: NewHolder(nil),
http: client,
running: make(map[string]bool),
trigger: make(chan string, 16),
stop: make(chan struct{}),
}
m.stats = NewStateHolder(&State{Errors: map[string]string{}})
return m
}
// Holder returns the compiled-set holder the engine reads.
func (m *Manager) Holder() *Holder { return m.hold }
// State returns the detailed state the API serves.
func (m *Manager) State() *State { return m.stats.Get() }
// RefreshAll compiles every enabled source plus the local zones. The previous
// set is kept if every source fails, so a bad deploy never leaves the
// resolver with no blocklists at all.
func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
start := time.Now()
cfg := m.cfg.Get()
b := NewBuilder()
var (
sources []Source
errs = map[string]string{}
)
// Inline block/allow entries first: they are cheap, and putting them
// before the lists means a later list cannot override an operator's
// inline decision without a priority bump.
if len(cfg.Block.BlockEntries) > 0 {
rules, st, err := parseInline(cfg.Block.BlockEntries, ActionBlock, cfg.Block.ResponseTTL)
if err == nil {
for _, r := range rules {
b.Add(r, "inline:block", nil)
}
b.NoteSource(SourceMeta{
Name: "inline:block", Format: string(FormatPlain), Rules: len(rules),
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
})
} else {
errs["inline:block"] = err.Error()
}
}
if len(cfg.Block.AllowEntries) > 0 {
rules, st, err := parseInline(cfg.Block.AllowEntries, ActionAllow, cfg.Block.ResponseTTL)
if err == nil {
for _, r := range rules {
b.Add(r, "inline:allow", nil)
}
b.NoteSource(SourceMeta{
Name: "inline:allow", Format: string(FormatPlain), Rules: len(rules),
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
})
} else {
errs["inline:allow"] = err.Error()
}
}
// Local RPZ-style zone files.
for _, pattern := range cfg.Block.LocalZones {
files, err := filepath.Glob(m.cfg.Get().AbsPath(pattern))
if err != nil || len(files) == 0 {
// A glob that matches nothing is a configuration smell, not a
// reason to fail the whole compile.
errs[pattern] = "no files matched"
continue
}
sort.Strings(files)
rules, st, err := parseRPZFiles(files)
if err != nil {
errs[pattern] = err.Error()
continue
}
name := "local:" + pattern
for _, r := range rules {
b.Add(r, name, nil)
}
b.NoteSource(SourceMeta{
Name: name, Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes,
UpdatedAt: start, Duration: time.Since(start), Enabled: true, Path: pattern,
})
}
// Remote / on-disk lists.
for _, l := range cfg.Block.Lists {
if !l.Enabled {
b.NoteSource(SourceMeta{
Name: l.Name, Format: l.Format, Enabled: false, URL: l.URL,
Path: cfg.ListPath(l),
})
continue
}
if !m.claim(l.Name) {
errs[l.Name] = "refresh already in flight"
continue
}
src, err := m.loadOne(ctx, cfg, l)
m.release(l.Name)
if err != nil {
errs[l.Name] = err.Error()
// Keep the previous content: record what we know about the
// source so the UI can show it as stale rather than missing.
if prev, ok := m.previous(l.Name); ok {
src = rawSource{
Name: prev.Name, Format: prev.Format, Meta: prev.Meta,
Stats: prev.Stats, Digest: prev.Digest,
}
src.Meta.Err = err.Error()
src.Meta.Stale = true
} else {
continue
}
}
for _, r := range src.rules {
b.Add(r, l.Name, l.Categories)
}
sources = append(sources, src.toSource())
}
// Carry forward bookkeeping for sources that contributed nothing this
// round (disabled, or skipped because a refresh was already in flight), so
// the UI keeps showing them. Only the *metadata* is carried: their rules
// are deliberately not re-added, because a disabled source must stop
// matching, and a source that failed its refresh must fall back to the
// copy the manager kept for it (above), not to a silent re-add.
for _, prev := range m.previousAll() {
if _, ok := b.metaFor(prev.Name); ok {
continue
}
carried := prev
carried.Meta.Rules = 0
sources = append(sources, carried)
}
set := b.Build()
state := State{
Set: set,
Sources: sources,
Duration: time.Since(start),
Errors: errs,
Digest: set.Digest(),
}
m.hold.Set(set)
m.stats.cur.Store(&state)
m.log.Info("blocklist compiled",
"rules", set.Len(), "sources", len(sources), "errors", len(errs),
"duration", state.Duration)
return state, nil
}
func (m *Manager) previous(name string) (Source, bool) {
st := m.stats.Get()
if st == nil {
return Source{}, false
}
for _, s := range st.Sources {
if s.Name == name {
return s, true
}
}
return Source{}, false
}
func (m *Manager) previousAll() []Source {
st := m.stats.Get()
if st == nil {
return nil
}
return st.Sources
}
func (m *Manager) claim(name string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.running[name] {
return false
}
m.running[name] = true
return true
}
func (m *Manager) release(name string) {
m.mu.Lock()
delete(m.running, name)
m.mu.Unlock()
}
// loadOne fetches and compiles a single list source.
func (m *Manager) loadOne(ctx context.Context, cfg *config.Config, l config.ListSource) (rawSource, error) {
start := time.Now()
path := cfg.ListPath(l)
var (
data []byte
etag = l.ETag
lastMod = l.LastModified
httpStatus int
)
needFetch := true
if path != "" {
// Prefer the local copy: it is what a restart without network sees.
if fi, err := os.Stat(path); err == nil {
if fi.Size() < l.MaxBytes {
data, _ = os.ReadFile(path)
needFetch = false
}
}
}
if l.URL != "" && needFetch {
fetched, status, newETag, newLM, err := m.fetch(ctx, l)
if err != nil {
if len(data) == 0 {
return rawSource{}, fmt.Errorf("fetch: %w", err)
}
// Keep the local copy and report the failure; a stale list beats
// no list.
httpStatus = status
} else {
data = fetched
httpStatus = status
if newETag != "" {
etag = newETag
}
if newLM != "" {
lastMod = newLM
}
if path != "" && !l.InsecureSkipTLSVerify {
// Persist for offline restarts. A write failure is not fatal.
_ = config.WriteFileAtomic(path, data, 0o644)
}
}
} else if l.URL != "" && !needFetch {
// We have a local copy; try a conditional refresh in the background
// anyway, but do not fail when it 304s.
httpStatus = http.StatusOK
}
if len(data) == 0 {
return rawSource{}, errors.New("no data available")
}
format := Format(l.Format)
opt := ParseOptions{
Subdomains: true,
DefaultTTL: uint32(cfg.Block.ResponseTTL.D()),
Categories: l.Categories,
}
rules, st, perr := Parse(format, data, opt)
if perr != nil {
return rawSource{}, perr
}
meta := SourceMeta{
Name: l.Name,
Format: string(st.Format),
Rules: len(rules),
Bytes: int64(len(data)),
ETag: etag,
LastMod: lastMod,
UpdatedAt: start,
Duration: time.Since(start),
Enabled: true,
URL: l.URL,
Path: path,
HTTPStatus: httpStatus,
}
return rawSource{
Name: l.Name,
Format: st.Format,
Meta: meta,
Stats: st,
Digest: digestOf(data),
rules: rules,
}, nil
}
// rawSource is the internal shape before it becomes a Source.
type rawSource struct {
Name string
Format Format
Meta SourceMeta
Stats ParseStats
Digest string
rules []Rule
}
func (r rawSource) toSource() Source {
return Source{Name: r.Name, Format: r.Format, Meta: r.Meta, Stats: r.Stats, Digest: r.Digest}
}
// fetch performs a conditional GET against a list URL, following mirrors.
func (m *Manager) fetch(ctx context.Context, l config.ListSource) (data []byte, status int, etag, lastMod string, err error) {
urls := append([]string{l.URL}, l.Mirror...)
var lastErr error
for _, u := range urls {
if u == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
lastErr = err
continue
}
req.Header.Set("User-Agent", "fancy-dns/1.0 (+blocklist)")
if l.ETag != "" {
req.Header.Set("If-None-Match", l.ETag)
}
if l.LastModified != "" {
req.Header.Set("If-Modified-Since", l.LastModified)
}
client := m.http
if l.InsecureSkipTLSVerify {
// Only for self-hosted mirrors with a private CA; the config
// field name says what it does.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = insecureTLS()
client = &http.Client{Transport: tr, Timeout: client.Timeout}
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
status = resp.StatusCode
switch resp.StatusCode {
case http.StatusNotModified:
resp.Body.Close()
return nil, status, l.ETag, l.LastModified, nil
case http.StatusOK:
etag = resp.Header.Get("ETag")
lastMod = resp.Header.Get("Last-Modified")
body, rerr := readLimited(resp.Body, l.MaxBytes)
resp.Body.Close()
if rerr != nil {
lastErr = rerr
continue
}
return body, status, etag, lastMod, nil
default:
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
lastErr = fmt.Errorf("%s: unexpected status %s", u, resp.Status)
}
}
if lastErr == nil {
lastErr = errors.New("no usable source")
}
return nil, 0, "", "", lastErr
}
func readLimited(r io.Reader, max int64) ([]byte, error) {
if max <= 0 {
max = 512 << 20
}
buf, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(buf)) > max {
return nil, fmt.Errorf("list exceeds %d bytes", max)
}
return buf, nil
}
// Start runs the scheduled refresh loop until ctx is cancelled.
func (m *Manager) Start(ctx context.Context) {
m.wg.Add(1)
go func() {
defer m.wg.Done()
// Stagger the first pass across sources so a cold cluster does not
// fetch 20 lists at the same instant.
for _, l := range m.cfg.Get().Block.Lists {
if !l.Enabled || l.Refresh <= 0 {
continue
}
jitter := l.Refresh.D() * time.Duration(fraction(l.Name)) / 8
select {
case <-ctx.Done():
return
case <-time.After(jitter):
}
if m.claim(l.Name) {
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("initial blocklist refresh failed", "error", err)
}
m.release(l.Name)
}
// Only stagger the first pass; after that the loop is regular.
break
}
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("initial blocklist refresh failed", "error", err)
}
m.loop(ctx)
}()
}
func (m *Manager) loop(ctx context.Context) {
// A 5-minute tick is coarse enough to be free and fine enough that a
// source's own refresh interval is honoured within 5 minutes.
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case name := <-m.trigger:
// A cluster peer asked for a refresh; it is the leader's job.
if name != "" && !m.claim(name) {
continue
}
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("triggered refresh failed", "source", name, "error", err)
}
if name != "" {
m.release(name)
}
case <-t.C:
m.refreshDue(ctx)
}
}
}
// refreshDue refreshes the sources whose interval has elapsed.
func (m *Manager) refreshDue(ctx context.Context) {
cfg := m.cfg.Get()
now := time.Now()
var due bool
for _, l := range cfg.Block.Lists {
if !l.Enabled || l.Refresh <= 0 {
continue
}
src, ok := m.previous(l.Name)
if !ok || src.Meta.UpdatedAt.IsZero() {
due = true
break
}
if now.Sub(src.Meta.UpdatedAt) >= l.Refresh.D() {
due = true
break
}
}
if !due {
return
}
// Refreshing every due source in one pass compiles once, which is far
// cheaper than one compile per source.
if _, err := m.RefreshAll(ctx); err != nil {
m.log.Warn("scheduled refresh failed", "error", err)
}
}
// RefreshOne refreshes a single named source immediately.
func (m *Manager) RefreshOne(ctx context.Context, name string) (State, error) {
if !m.claim(name) {
return *m.stats.Get(), fmt.Errorf("refresh of %q already in flight", name)
}
defer m.release(name)
return m.RefreshAll(ctx)
}
// Trigger asks the refresh loop to compile now. Safe to call from an API
// handler; it does not block.
func (m *Manager) Trigger(name string) {
select {
case m.trigger <- name:
default:
}
}
// Stop shuts the loop down.
func (m *Manager) Stop() {
select {
case <-m.stop:
default:
close(m.stop)
}
m.wg.Wait()
}
// parseInline compiles the config's inline entry lists. The plain-domain
// parser yields block rules, so the requested action is stamped on afterwards:
// an operator's allowlist must not be compiled as a blocklist, which is the
// kind of bug that silently inverts a policy.
func parseInline(entries []string, action Action, ttl config.Duration) ([]Rule, ParseStats, error) {
var sb strings.Builder
for _, e := range entries {
sb.WriteString(e)
sb.WriteByte('\n')
}
opt := ParseOptions{Subdomains: true, DefaultTTL: uint32(ttl.D())}
rules, st, err := Parse(FormatPlain, []byte(sb.String()), opt)
if err != nil {
return nil, st, err
}
for i := range rules {
if rules[i].Action == ActionBlock {
rules[i].Action = action
}
}
return rules, st, nil
}
func parseRPZFiles(files []string) ([]Rule, ParseStats, error) {
var sb strings.Builder
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
return nil, ParseStats{}, err
}
// Prefix each file with its own $ORIGIN so a multi-file policy zone
// keeps the per-file origins from its file name.
fmt.Fprintf(&sb, "$ORIGIN %s\n", zonefile.OriginFromPath(f))
sb.Write(data)
sb.WriteByte('\n')
}
opt := ParseOptions{Subdomains: true, DefaultTTL: 60}
return Parse(FormatRPZ, []byte(sb.String()), opt)
}
func digestOf(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:8])
}
// fraction maps a name to a stable 0..1 value, for refresh jitter.
func fraction(name string) int {
sum := sha256.Sum256([]byte(name))
return int(sum[0]) // 0..255
}
// insecureTLS is the transport tweak for sources that need it. It is only
// reachable when a list explicitly sets insecure_skip_tls_verify.
func insecureTLS() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
+535
View File
@@ -0,0 +1,535 @@
package blocklist
import (
"bufio"
"bytes"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Format identifies a list's syntax.
type Format string
const (
// FormatAuto sniffs the content.
FormatAuto Format = "auto"
// FormatHosts is "IP domain [domain...]" (also accepts "0 domain").
FormatHosts Format = "hosts"
// FormatAdblock is the Adblock Plus / EasyList syntax.
FormatAdblock Format = "adblock"
// FormatPlain is one domain per line.
FormatPlain Format = "plain"
// FormatRPZ is an RFC 1035 response policy zone.
FormatRPZ Format = "rpz"
)
// ParseOptions tunes the parsers.
type ParseOptions struct {
// Subdomains makes a bare domain also cover its subdomains. Defaults to
// true; hosts files rely on it, and precise lists can opt out.
Subdomains bool
// DefaultTTL is applied to rules that carry none.
DefaultTTL uint32
// Categories are attached to every rule from this source.
Categories []string
}
// ParseStats reports what a parser saw, for the UI and for tests.
type ParseStats struct {
Rules int `json:"rules"`
Comments int `json:"comments"`
Skipped int `json:"skipped"`
Malformed int `json:"malformed"`
Duplicates int `json:"duplicates"`
Bytes int64 `json:"bytes"`
DurationMS float64 `json:"duration_ms"`
Format Format `json:"format"`
Sample []string `json:"sample,omitempty"`
Notices []string `json:"notices,omitempty"`
}
// DetectFormat sniffs a list's syntax from its first meaningful lines.
func DetectFormat(data []byte) Format {
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
sawRecord := false
for i := 0; sc.Scan() && i < 200; i++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") {
// "[Adblock Plus 2.0]" is a header, keep looking.
continue
}
if strings.HasPrefix(line, "$TTL") || strings.HasPrefix(line, "$ORIGIN") ||
strings.HasPrefix(line, "$INCLUDE") {
return FormatRPZ
}
if sawRecord {
return FormatAdblock
}
fields := strings.Fields(line)
if len(fields) >= 2 && isAddr(fields[0]) {
return FormatHosts
}
if strings.HasPrefix(line, "||") || strings.HasPrefix(line, "|") ||
strings.HasPrefix(line, "@@") || strings.Contains(line, "##") ||
strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
return FormatAdblock
}
if len(fields) >= 3 && len(fields[1]) > 1 && allDigitsOrName(fields[1]) {
// "name 60 IN A 1.2.3.4" style.
return FormatRPZ
}
sawRecord = true
}
return FormatPlain
}
func allDigitsOrName(s string) bool {
if _, err := strconv.Atoi(s); err == nil {
return true
}
switch strings.ToUpper(s) {
case "IN", "CH", "HS":
return true
}
return false
}
// Parse converts list data into rules using the given format (FormatAuto
// sniffs). It never returns partial garbage: on a hard error the rules
// accumulated so far are returned alongside it so the caller can decide
// whether to keep the last good set or adopt the partial one.
func Parse(format Format, data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
if opt.DefaultTTL == 0 {
opt.DefaultTTL = 60
}
if format == "" || format == FormatAuto {
format = DetectFormat(data)
}
stats := ParseStats{Bytes: int64(len(data)), Format: format}
var (
rules []Rule
err error
)
switch format {
case FormatHosts:
rules, stats, err = parseHosts(data, opt)
case FormatAdblock:
rules, stats, err = parseAdblock(data, opt)
case FormatPlain:
rules, stats, err = parsePlain(data, opt)
case FormatRPZ:
rules, stats, err = parseRPZ(data, opt)
default:
return nil, stats, fmt.Errorf("blocklist: unknown format %q", format)
}
return rules, stats, err
}
func parseHosts(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatHosts}
sc := newLineScanner(data)
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed[0] == '#' || trimmed[0] == ';' {
stats.Comments++
continue
}
// Strip inline comments.
if i := strings.IndexByte(trimmed, '#'); i >= 0 {
trimmed = strings.TrimSpace(trimmed[:i])
if trimmed == "" {
stats.Comments++
continue
}
}
fields := strings.Fields(trimmed)
if len(fields) < 2 {
stats.Malformed++
continue
}
addr := fields[0]
if !isAddr(addr) && !strings.EqualFold(addr, "localhost") {
// Not an address: this is not a hosts file after all, so treat
// the whole line as a domain list.
stats.Notices = appendNotice(stats.Notices, "no address in first field, falling back to a plain domain list")
return parsePlain(data, opt)
}
names := fields[1:]
if isLoopbackName(names) {
// "127.0.0.1 localhost" style bootstrap lines: never block these.
stats.Skipped++
continue
}
for _, n := range names {
if !validDomain(n) {
stats.Malformed++
continue
}
rules = append(rules, hostsRule(n, opt))
}
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
func isLoopbackName(names []string) bool {
for _, n := range names {
switch strings.ToLower(strings.TrimSuffix(n, ".")) {
case "localhost", "localhost.localdomain", "broadcasthost", "ip6-localhost", "ip6-loopback":
return true
}
}
return false
}
// hostsRule builds a rule for a hosts-file or plain-list entry.
//
// A hosts file says "this name is bad" and everyone means the name *and*
// anything under it, so the pattern is "**." (apex included) rather than the
// strict-subdomain "*.". Getting this wrong is how a filter ends up blocking
// ads.example.com but happily resolving 1.ads.example.com.
func hostsRule(name string, opt ParseOptions) Rule {
pattern := name
if opt.Subdomains {
pattern = "**." + name
}
return Rule{Name: pattern, Action: ActionBlock, TTL: opt.DefaultTTL}
}
func parseAdblock(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatAdblock}
sc := newLineScanner(data)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if line[0] == '!' || line[0] == '#' {
stats.Comments++
continue
}
if line[0] == '[' {
stats.Comments++
continue
}
if strings.HasPrefix(line, "||") {
host := hostOfAdblock(line[2:])
if !validDomain(host) {
stats.Skipped++
continue
}
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
continue
}
if strings.HasPrefix(line, "@@") {
rest := strings.TrimPrefix(line, "@@")
host := hostOfAdblock(strings.TrimPrefix(rest, "||"))
if !validDomain(host) {
stats.Skipped++
continue
}
rules = append(rules, Rule{Name: "**." + host, Action: ActionAllow, Priority: 10, TTL: opt.DefaultTTL})
continue
}
if strings.HasPrefix(line, "|") {
rest := strings.TrimPrefix(line, "|")
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
host := hostOfURL(rest)
if !validDomain(host) {
stats.Skipped++
continue
}
// A path rule cannot be expressed in DNS; blocking the host
// is the closest faithful approximation, so record it once.
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
continue
}
stats.Skipped++
continue
}
if line[0] == '/' && strings.HasSuffix(line, "/") {
stats.Skipped++ // regular expressions are not DNS-expressible
if len(stats.Notices) < 5 {
stats.Notices = appendNotice(stats.Notices, "skipped regular-expression rules")
}
continue
}
// Element-hiding rules ("##", "#@#", "#?#", "##+js(") carry no DNS
// meaning at all.
if strings.Contains(line, "##") || strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
stats.Skipped++
continue
}
// A bare hostname with no adblock syntax at all.
if validDomain(line) {
rules = append(rules, hostsRule(line, opt))
continue
}
stats.Skipped++
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
// hostOfAdblock extracts the domain from the part of an adblock rule between
// "||" (or a scheme) and the first separator (^, /, ?, |, $).
func hostOfAdblock(s string) string {
if i := strings.IndexAny(s, "^/?|$"); i >= 0 {
s = s[:i]
}
return strings.Trim(s, ".")
}
func hostOfURL(s string) string {
if i := strings.Index(s, "://"); i >= 0 {
s = s[i+3:]
}
if i := strings.IndexAny(s, "/?#"); i >= 0 {
s = s[:i]
}
if i := strings.LastIndexByte(s, '@'); i >= 0 {
s = s[i+1:]
}
// Strip credentials and port.
if i := strings.LastIndexByte(s, ':'); i >= 0 {
if _, err := strconv.Atoi(s[i+1:]); err == nil {
s = s[:i]
}
}
return strings.Trim(s, ".")
}
func parsePlain(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatPlain}
sc := newLineScanner(data)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if line[0] == '#' || line[0] == ';' {
stats.Comments++
continue
}
// Accept "0.0.0.0 domain" and bare "domain" alike so operators do
// not have to normalise their sources by hand.
if fields := strings.Fields(line); len(fields) > 1 && isAddr(fields[0]) {
for _, n := range fields[1:] {
if validDomain(n) {
rules = append(rules, hostsRule(n, opt))
} else {
stats.Malformed++
}
}
continue
}
// Support inline allow markers: "!allow example.com".
allow := false
if strings.HasPrefix(line, "!") {
allow = true
line = strings.TrimSpace(line[1:])
}
line = strings.TrimPrefix(line, "||")
if !validDomain(line) {
stats.Malformed++
continue
}
pattern := line
if opt.Subdomains && !strings.HasPrefix(line, "*") {
pattern = "**." + line
}
action := ActionBlock
if allow {
action = ActionAllow
}
rules = append(rules, Rule{Name: pattern, Action: action, TTL: opt.DefaultTTL})
}
if err := sc.Err(); err != nil {
return rules, stats, err
}
stats.Rules = len(rules)
return rules, stats, nil
}
func parseRPZ(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
var rules []Rule
stats := ParseStats{Bytes: int64(len(data)), Format: FormatRPZ}
z, err := zonefile.Parse(bytes.NewReader(data), zonefile.Options{DefaultTTL: opt.DefaultTTL})
if err != nil {
return nil, stats, err
}
seen := make(map[string]bool, len(z.Records))
for _, rec := range z.Records {
ttl := rec.TTL
if ttl == 0 && opt.DefaultTTL == 0 {
ttl = opt.DefaultTTL
}
rule, ok := rpzRule(rec, ttl, opt)
if !ok {
stats.Skipped++
continue
}
if seen[rule.Name] {
stats.Duplicates++
continue
}
seen[rule.Name] = true
rules = append(rules, rule)
}
stats.Rules = len(rules)
return rules, stats, nil
}
// rpzRule maps one policy-zone record onto a rule (RFC draft-ietf-dnsop-rpz).
// RPZ encodes the response in the rdata: a CNAME to "." means NXDOMAIN, to
// "rpz-drop." means drop, to "rpz-passthru." means resolve normally, and
// A/AAAA records return that address verbatim.
func rpzRule(rec zonefile.Record, ttl uint32, opt ParseOptions) (Rule, bool) {
pattern := rec.Name
if !strings.HasPrefix(pattern, "*") && opt.Subdomains {
pattern = "*." + pattern
}
target, action, resp := rpzTarget(rec.RR)
if target == "" {
return Rule{}, false
}
return Rule{Name: pattern, Action: action, Response: resp, TTL: ttl}, true
}
// rpzTarget interprets a policy record's rdata. The returned target is only a
// "is this actionable" flag; action and resp carry the meaning.
func rpzTarget(rr dns.RR) (target string, action Action, resp string) {
switch v := rr.(type) {
case *dns.CNAME:
t := v.Target
switch {
case t == ".":
// The classic RPZ "this name does not exist" trigger.
return ".", ActionNXDomain, ""
case strings.EqualFold(t, "rpz-drop."):
return t, ActionDrop, ""
case strings.EqualFold(t, "rpz-passthru."), strings.EqualFold(t, "rpz-passthru."):
return t, ActionPassthrough, ""
case strings.EqualFold(t, "rpz-tcp-only."):
// Force TCP upstream; as a DNS policy this is a no-op for us
// since we already retry over TCP on truncation.
return t, ActionPassthrough, ""
default:
return t, ActionCNAME, t
}
case *dns.A:
if v.A == nil {
return "", ActionBlock, ""
}
return v.A.String(), ActionRedirect, v.A.String()
case *dns.AAAA:
if v.AAAA == nil {
return "", ActionBlock, ""
}
return v.AAAA.String(), ActionRedirect, v.AAAA.String()
case *dns.TXT:
// Some providers publish policy in TXT records; the payload is the
// action name.
t := strings.ToLower(strings.Join(v.Txt, ""))
switch {
case strings.Contains(t, "rpz-drop"):
return t, ActionDrop, ""
case strings.Contains(t, "rpz-passthru"):
return t, ActionPassthrough, ""
case strings.Contains(t, "allow"), strings.Contains(t, "whitelist"):
return t, ActionAllow, ""
default:
return "", ActionBlock, ""
}
default:
return "", ActionBlock, ""
}
}
// isAddr reports whether s parses as an IP address literal.
func isAddr(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// newLineScanner returns a line scanner sized for the very large list files
// real blocklists ship (multi-hundred-megabyte entries are not unheard of).
func newLineScanner(data []byte) *bufio.Scanner {
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 128*1024), 4*1024*1024)
return sc
}
func appendNotice(notices []string, n string) []string {
for _, existing := range notices {
if existing == n {
return notices
}
}
if len(notices) >= 8 {
return notices
}
return append(notices, n)
}
// validDomain is a cheap sanity check: no scheme, no path, printable, and at
// least one dot or a known single-label TLD-ish name.
func validDomain(s string) bool {
if s == "" || len(s) > 253 {
return false
}
if strings.HasPrefix(s, ".") || strings.HasPrefix(s, "-") {
return false
}
if strings.ContainsAny(s, "/ \t\\\"'<>|?*=") {
return false
}
labels := strings.Split(strings.TrimSuffix(s, "."), ".")
if len(labels) < 1 {
return false
}
for _, l := range labels {
if l == "" || len(l) > 63 {
return false
}
for i := 0; i < len(l); i++ {
c := l[i]
ok := c == '-' || c == '_' || c == '*' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
if !ok {
return false
}
}
if l[0] == '-' || l[len(l)-1] == '-' {
return false
}
}
// A bare label with no dot is only meaningful for the localhost-ish
// names, which callers already filter; reject to avoid blocking a
// single-label intranet name by accident.
return strings.Contains(s, ".")
}
+737
View File
@@ -0,0 +1,737 @@
// Package blocklist compiles heterogeneous block/allow sources (hosts files,
// adblock lists, plain domain lists and RPZ policy zones) into a single
// immutable Set that answers "what should I do with this name?" in a handful
// of map lookups.
//
// A Set is never mutated after construction; Manager swaps a whole new Set in
// atomically when lists are refreshed, so a query either sees the old rules or
// the new ones, never a half-applied mixture.
package blocklist
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"io"
"strings"
"sync/atomic"
"time"
)
// Action is what to do with a matched name.
type Action uint8
const (
// ActionBlock uses the manager's default response (nxdomain/zero/...).
ActionBlock Action = iota
// ActionAllow whitelists the name: blocklists are skipped entirely.
ActionAllow
// ActionPassthrough resolves the name upstream but ignores blocklists
// (RPZ rpz-passthru).
ActionPassthrough
// ActionDrop answers nothing at all (RPZ rpz-drop).
ActionDrop
// ActionNXDomain answers NXDOMAIN regardless of the default response.
ActionNXDomain
// ActionZero answers the configured sinkhole address.
ActionZero
// ActionCNAME answers a CNAME to Response.
ActionCNAME
// ActionRedirect answers A/AAAA for Response as an address literal.
ActionRedirect
)
func (a Action) String() string {
switch a {
case ActionBlock:
return "block"
case ActionAllow:
return "allow"
case ActionPassthrough:
return "passthrough"
case ActionDrop:
return "drop"
case ActionNXDomain:
return "nxdomain"
case ActionZero:
return "zero"
case ActionCNAME:
return "cname"
case ActionRedirect:
return "redirect"
}
return "unknown"
}
// ParseAction maps configuration and UI strings onto an Action.
func ParseAction(s string) (Action, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "block":
return ActionBlock, true
case "allow", "whitelist":
return ActionAllow, true
case "passthrough", "pass":
return ActionPassthrough, true
case "drop":
return ActionDrop, true
case "nxdomain":
return ActionNXDomain, true
case "zero", "sinkhole", "null":
return ActionZero, true
case "cname":
return ActionCNAME, true
case "redirect":
return ActionRedirect, true
}
return ActionBlock, false
}
// Entry is one compiled rule. It is shared by every name that maps to it, so
// keep it small: the strings are interned per source.
//
// The hit counter is a pointer so an Entry can be copied by value (the API
// returns copies) without copying a mutex or tripping go vet's lock check.
type Entry struct {
// Name is the rule's pattern, normalised (lowercase, no trailing dot).
Name string
Action Action
// Response is the sinkhole address or CNAME target for the actions that
// need one; empty otherwise.
Response string
// Source is the list or file the rule came from (shown in the UI).
Source string
// Categories are the list's tags, copied per rule.
Categories []string
// Priority orders allow-over-block conflicts: higher wins, and ties break
// towards the first rule seen.
Priority int
// hits counts matches since the set was built. A pointer keeps Entry
// copyable by value, which the admin API relies on.
hits *atomic.Uint64
// TTL overrides the manager's response TTL for this rule (RPZ records
// carry their own TTL).
TTL uint32
}
// Hits returns how many times this rule matched.
func (e *Entry) Hits() uint64 {
if e == nil || e.hits == nil {
return 0
}
return e.hits.Load()
}
func (e *Entry) markHit() {
if e == nil || e.hits == nil {
return
}
e.hits.Add(1)
}
// IsAllow reports whether the rule whitelists the name.
func (e *Entry) IsAllow() bool {
return e.Action == ActionAllow || e.Action == ActionPassthrough
}
// Rule is a pattern plus the action it implies, as produced by the parsers.
type Rule struct {
// Name is the pattern; may start with "*." (subdomains only) or
// "**." (subdomains and the apex itself).
Name string
Action Action
// Response is an address literal (for redirect) or a target name (cname).
Response string
// TTL is a per-rule TTL when the source provides one.
TTL uint32
// Category tags the rule.
Category string
// Priority disambiguates allow/block conflicts.
Priority int
}
// Set is an immutable compiled rule table.
//
// Three maps cover the pattern space:
//
// exact[name] "ads.example.com" the name and nothing else
// sub[example.com] "*.example.com" strict subdomains
// root[example.com] "**.example.com" subdomains and the apex
//
// Lookup is one hash for the common case (exact hit or "no wildcards at all"),
// and at most one hash per label otherwise — no per-query allocation.
type Set struct {
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
// hasWild lets Match skip the suffix walk for sets of pure exact rules,
// which is the common hosts-file case.
hasWild bool
// meta is per-source bookkeeping for the UI.
meta map[string]SourceMeta
// counts is the number of compiled rules, per kind.
counts RuleCounts
// total is the number of rules dropped as duplicates or malformed.
duplicates int
// builtAt is when this set was compiled.
builtAt time.Time
// digest covers the compiled patterns, so two instances can compare rules
// without shipping the rules around.
digest string
// sources is the ordered list of source names that contributed.
sources []string
// maxDepth is the longest pattern label count, used to bound the walk.
maxDepth int
}
// RuleCounts breaks down compiled rules by kind.
type RuleCounts struct {
Exact int
Sub int
Root int
Allow int
Block int
Passthru int
Drop int
}
// SourceMeta describes one contributing source.
type SourceMeta struct {
Name string
Format string
Rules int
Bytes int64
ETag string
LastMod string
UpdatedAt time.Time
Duration time.Duration
Err string
Enabled bool
URL string
Path string
Stale bool
HTTPStatus int
}
// Builder accumulates rules and produces a Set.
type Builder struct {
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
meta map[string]SourceMeta
order []string
dup int
counts RuleCounts
}
// NewBuilder returns an empty Builder.
func NewBuilder() *Builder {
return &Builder{
exact: make(map[string]*Entry, 1<<12),
sub: make(map[string]*Entry, 1<<10),
root: make(map[string]*Entry, 1<<10),
meta: make(map[string]SourceMeta, 8),
}
}
// Add compiles one rule. Names are normalised here, so callers may pass
// user-typed domain names.
func (b *Builder) Add(r Rule, source string, categories []string) {
name, kind := splitPattern(r.Name)
if name == "" {
return
}
prio := r.Priority
if prio == 0 {
// Allow rules outrank block rules by default so an explicit
// whitelist always wins over a list entry.
if r.Action == ActionAllow || r.Action == ActionPassthrough {
prio = 10
} else {
prio = 1
}
}
e := &Entry{
hits: new(atomic.Uint64),
Name: name,
Action: r.Action,
Response: r.Response,
Source: source,
Categories: categories,
Priority: prio,
TTL: r.TTL,
}
if r.Category != "" {
e.Categories = append(e.Categories, r.Category)
}
var m map[string]*Entry
switch kind {
case patternExact:
m = b.exact
case patternSub:
m = b.sub
case patternRoot:
m = b.root
}
if existing, ok := m[name]; ok {
b.dup++
// Higher priority wins; on a tie the existing rule stays, so the
// order lists appear in the config is meaningful.
if prio <= existing.Priority {
return
}
b.uncount(existing)
m[name] = e
b.countAction(e, 1)
return
}
m[name] = e
b.countAction(e, 1)
if _, seen := b.meta[source]; !seen {
b.order = append(b.order, source)
}
}
// metaFor reports whether a source already contributed to this build.
func (b *Builder) metaFor(name string) (SourceMeta, bool) {
m, ok := b.meta[name]
return m, ok
}
func (b *Builder) countAction(e *Entry, delta int) {
switch e.Action {
case ActionAllow:
b.counts.Allow += delta
case ActionPassthrough:
b.counts.Passthru += delta
case ActionDrop:
b.counts.Drop += delta
default:
b.counts.Block += delta
}
}
// uncount removes an entry that a higher-priority rule is replacing.
func (b *Builder) uncount(e *Entry) {
switch e.Action {
case ActionAllow:
b.counts.Allow--
case ActionPassthrough:
b.counts.Passthru--
case ActionDrop:
b.counts.Drop--
default:
b.counts.Block--
}
}
// NoteSource records (or updates) bookkeeping for a contributing source.
func (b *Builder) NoteSource(meta SourceMeta) {
if _, seen := b.meta[meta.Name]; !seen {
b.order = append(b.order, meta.Name)
}
b.meta[meta.Name] = meta
}
// SourceCount is the number of contributing sources.
func (b *Builder) SourceCount() int { return len(b.meta) }
// Build freezes the accumulated rules into an immutable Set.
func (b *Builder) Build() *Set {
s := &Set{
exact: b.exact,
sub: b.sub,
root: b.root,
meta: b.meta,
sources: b.order,
builtAt: time.Now(),
duplicates: b.dup,
hasWild: len(b.sub) > 0 || len(b.root) > 0,
}
s.counts = b.counts
s.counts.Exact = len(b.exact)
s.counts.Sub = len(b.sub)
s.counts.Root = len(b.root)
s.digest = b.digest()
for k := range b.exact {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
for k := range b.sub {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
for k := range b.root {
if d := labelCount(k); d > s.maxDepth {
s.maxDepth = d
}
}
return s
}
// digest hashes every compiled pattern together with its action. The map walk
// makes it order-independent, but stable for a given rule set, which is exactly
// what cluster convergence checking needs.
func (b *Builder) digest() string {
h := sha256.New()
var buf [3]byte
write := func(kind byte, name string, e *Entry) {
buf[0] = kind
h.Write(buf[:1])
io.WriteString(h, name)
buf[1] = byte(e.Action)
binary.LittleEndian.PutUint16(buf[1:], uint16(e.Priority))
h.Write(buf[:3])
}
for name, e := range b.exact {
write(0, name, e)
}
for name, e := range b.sub {
write(1, name, e)
}
for name, e := range b.root {
write(2, name, e)
}
return hex.EncodeToString(h.Sum(nil)[:8])
}
// Digest returns the compiled content's digest.
func (s *Set) Digest() string {
if s == nil {
return ""
}
return s.digest
}
type patternKind uint8
const (
patternExact patternKind = iota
patternSub
patternRoot
)
// splitPattern normalises a rule name and reports which map it belongs in.
//
// "Example.COM." -> exact "example.com"
// "*.example.com" -> sub "example.com"
// "**.example.com" -> root "example.com"
// "." (the whole internet) is dropped: it is never a useful rule.
func splitPattern(in string) (string, patternKind) {
name := strings.ToLower(strings.TrimSpace(in))
if name == "" {
return "", patternExact
}
kind := patternExact
switch {
case strings.HasPrefix(name, "**."):
kind = patternRoot
name = name[3:]
case strings.HasPrefix(name, "*."):
kind = patternSub
name = name[2:]
case strings.HasPrefix(name, "."):
// A leading dot in hosts/adblock convention means "and subdomains".
kind = patternRoot
name = name[1:]
}
name = strings.TrimSuffix(name, ".")
name = strings.Trim(name, ".")
if name == "" || name == "." {
return "", patternExact
}
// A name with an interior "*" is not something we can match; treat the
// first label as a fixed prefix and drop the rest, which degrades to
// "closest sane behaviour" instead of silently not matching.
if i := strings.IndexByte(name, '*'); i >= 0 {
name = name[:i]
name = strings.TrimSuffix(name, ".")
if name == "" {
return "", patternExact
}
}
return name, kind
}
// NormalizeName canonicalises a queried name for matching: lowercased, no
// trailing dot. It is allocation-free for the common case.
func NormalizeName(name string) string {
if len(name) > 0 && name[len(name)-1] == '.' {
name = name[:len(name)-1]
}
return strings.ToLower(name)
}
// Match returns the rule that applies to name, or nil. name may be fqdn or
// relative; the caller is responsible for lowercasing only if it has not
// already.
func (s *Set) Match(name string) *Entry {
if s == nil {
return nil
}
name = NormalizeName(name)
if name == "" {
return nil
}
if e, ok := s.exact[name]; ok {
e.markHit()
return e
}
if !s.hasWild {
return nil
}
// The apex pattern "**.x" also covers the name itself.
if e, ok := s.root[name]; ok {
e.markHit()
return e
}
// Walk strictly shorter suffixes, most specific first.
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
suffix := name[off:]
if e, ok := s.root[suffix]; ok {
e.markHit()
return e
}
if e, ok := s.sub[suffix]; ok {
e.markHit()
return e
}
}
return nil
}
// labelEnd returns the index just past the first label of s, or len(s) when s
// has no dot.
func labelEnd(s string) int {
if i := strings.IndexByte(s, '.'); i >= 0 {
return i + 1
}
return len(s)
}
func labelCount(s string) int {
n := 1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
n++
}
}
return n
}
// Len reports the total number of compiled rules.
func (s *Set) Len() int {
if s == nil {
return 0
}
return s.counts.Exact + s.counts.Sub + s.counts.Root
}
// Counts returns the per-kind rule breakdown.
func (s *Set) Counts() RuleCounts {
if s == nil {
return RuleCounts{}
}
return s.counts
}
// HasWildcards reports whether any subdomain rules exist.
func (s *Set) HasWildcards() bool { return s != nil && s.hasWild }
// Duplicates is the number of rules dropped because a higher- or equal-priority
// rule already claimed the name.
func (s *Set) Duplicates() int { return s.DuplicatesN() }
// DuplicatesN is Duplicates with a nil guard.
func (s *Set) DuplicatesN() int {
if s == nil {
return 0
}
return s.duplicates
}
// BuiltAt is when the set was compiled.
func (s *Set) BuiltAt() time.Time {
if s == nil {
return time.Time{}
}
return s.builtAt
}
// Sources lists the contributing source names in order.
func (s *Set) Sources() []string {
if s == nil {
return nil
}
return s.sources
}
// SourceMeta returns bookkeeping for one source.
func (s *Set) SourceMeta(name string) (SourceMeta, bool) {
if s == nil {
return SourceMeta{}, false
}
m, ok := s.meta[name]
return m, ok
}
// AllSourceMeta returns the per-source bookkeeping map.
func (s *Set) AllSourceMeta() map[string]SourceMeta {
if s == nil {
return nil
}
return s.meta
}
// PatternKind says which of the three maps a rule lives in.
type PatternKind uint8
const (
// PatternExact is "ads.example.com": the name and nothing else.
PatternExact PatternKind = iota
// PatternSub is "*.example.com": strict subdomains.
PatternSub
// PatternRoot is "**.example.com": subdomains and the apex.
PatternRoot
)
// String renders the kind for the UI.
func (k PatternKind) String() string {
switch k {
case PatternSub:
return "subdomain"
case PatternRoot:
return "domain"
default:
return "exact"
}
}
// Pattern renders a pattern of this kind for a stored name.
func (k PatternKind) Pattern(name string) string {
switch k {
case PatternSub:
return "*." + name
case PatternRoot:
return "**." + name
default:
return name
}
}
// Each visits every rule. It exists for the admin API's rule browser; the DNS
// hot path uses Match and never pays this cost.
func (s *Set) Each(fn func(pattern string, e *Entry, kind PatternKind) bool) {
if s == nil {
return
}
for name, e := range s.exact {
if !fn(name, e, PatternExact) {
return
}
}
for name, e := range s.sub {
if !fn(name, e, PatternSub) {
return
}
}
for name, e := range s.root {
if !fn(name, e, PatternRoot) {
return
}
}
}
// MatchPattern returns the kind of rule that matched a name, and the stored
// pattern (without its wildcard prefix). It reports PatternExact when only an
// exact rule matched.
func (s *Set) MatchPattern(name string) PatternKind {
if s == nil {
return PatternExact
}
name = NormalizeName(name)
if _, ok := s.exact[name]; ok {
return PatternExact
}
if _, ok := s.root[name]; ok {
return PatternRoot
}
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
suffix := name[off:]
if _, ok := s.root[suffix]; ok {
return PatternRoot
}
if _, ok := s.sub[suffix]; ok {
return PatternSub
}
}
return PatternExact
}
// Lookup returns the exact rule for a name without walking wildcards. It is
// used by the admin API to explain a match.
func (s *Set) Lookup(name string) *Entry {
if s == nil {
return nil
}
return s.exact[NormalizeName(name)]
}
// Stats is a JSON-friendly snapshot for the API and UI.
type Stats struct {
Total int `json:"total"`
Exact int `json:"exact"`
Subdomains int `json:"subdomain_rules"`
RootRules int `json:"root_rules"`
Allow int `json:"allow"`
Block int `json:"block"`
Passthru int `json:"passthrough"`
Drop int `json:"drop"`
Duplicates int `json:"duplicates"`
Sources int `json:"sources"`
BuiltAt time.Time `json:"built_at"`
PerSource map[string]SourceMeta `json:"sources_detail"`
}
// Stats summarises the set for the API.
func (s *Set) Stats() Stats {
if s == nil {
return Stats{PerSource: map[string]SourceMeta{}}
}
return Stats{
Total: s.Len(),
Exact: s.counts.Exact,
Subdomains: s.counts.Sub,
RootRules: s.counts.Root,
Allow: s.counts.Allow,
Block: s.counts.Block,
Passthru: s.counts.Passthru,
Drop: s.counts.Drop,
Duplicates: s.duplicates,
Sources: len(s.meta),
BuiltAt: s.builtAt,
PerSource: s.meta,
}
}
// Holder is a swappable Set, so hot paths can grab a pointer once per query
// and be immune to a concurrent refresh.
type Holder struct {
cur atomic.Pointer[Set]
}
// NewHolder wraps an initial set (may be nil).
func NewHolder(s *Set) *Holder {
h := &Holder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current set (nil before the first build).
func (h *Holder) Get() *Set { return h.cur.Load() }
// Set installs a new set.
func (h *Holder) Set(s *Set) { h.cur.Store(s) }
+209
View File
@@ -0,0 +1,209 @@
// Package cache implements the hot answer cache: a sharded LRU over packed
// wire-format DNS messages, backed by a bbolt snapshot so a restart does not
// start cold.
//
// Design notes that matter for performance:
//
// - Entries are stored as packed wire bytes, not as *dns.Msg. A hit costs
// one Unpack and yields a private copy, so serving never mutates shared
// state and TTL ageing is free. This is the same trade dnsmasq makes.
// - Keys are Go strings built from the already-allocated question name, so
// the lookup path does not allocate on a hit.
// - The LRU is sharded by name hash: a hot domain only contends with other
// names in its own shard.
// - Expired-but-useful entries are kept for a serve-stale window so a dead
// upstream degrades latency instead of availability.
package cache
import (
"encoding/binary"
"strings"
"time"
"github.com/miekg/dns"
)
// Key identifies a cache entry. Scope lets several logically distinct
// resolvers share one cache (for example per-upstream-group ACL overrides)
// without colliding.
type Key struct {
Name string
Type uint16
Class uint16
// DO mirrors the request's DNSSEC OK bit. Since we pass AD/DO through
// without validating, a DO query legitimately gets a different answer.
DO bool
// Scope is a small caller-defined namespace, usually a group/ACL key.
Scope uint8
// CD (checking disabled) is not part of the key: RFC 4035 says a CD
// query is answered from a non-CD cached entry, which is what we do.
}
// QueryKey builds a Key from a DNS question.
func QueryKey(name string, qtype, qclass uint16, do bool, scope uint8) Key {
return Key{
Name: canonical(name),
Type: qtype,
Class: qclass,
DO: do,
Scope: scope,
}
}
// FromMsg builds a Key from the first question of a message.
func FromMsg(m *dns.Msg, scope uint8) (Key, bool) {
if len(m.Question) == 0 {
return Key{}, false
}
q := m.Question[0]
return QueryKey(q.Name, q.Qtype, q.Qclass, Do(m), scope), true
}
// Do reports whether the message carries the DNSSEC OK bit.
func Do(m *dns.Msg) bool {
if o := m.IsEdns0(); o != nil {
return o.Do()
}
return false
}
// canonical lowercases and strips the trailing dot of a name so that
// "Example.COM." and "example.com" share one entry.
func canonical(name string) string {
if n := len(name); n > 0 && name[n-1] == '.' {
name = name[:n-1]
}
// A fast path for the overwhelmingly common all-lowercase case keeps this
// allocation-free.
for i := 0; i < len(name); i++ {
c := name[i]
if c >= 'A' && c <= 'Z' {
return strings.ToLower(name)
}
}
return name
}
// packKey serialises a key for the bbolt snapshot. The wire format is
// length-prefixed so it is unambiguous and compact:
//
// varint len | wire name | uint16 type | uint16 class | uint8 flags
func packKey(buf []byte, k Key) ([]byte, error) {
name := k.Name
if n := len(name); n > 255*4 {
return nil, errKeyTooLong
}
var tmp [256]byte
wireLen, err := dns.PackDomainName(name, tmp[:], 0, nil, false)
if err != nil {
return nil, err
}
buf = binary.AppendUvarint(buf, uint64(wireLen))
buf = append(buf, tmp[:wireLen]...)
buf = binary.LittleEndian.AppendUint16(buf, k.Type)
buf = binary.LittleEndian.AppendUint16(buf, k.Class)
var flags uint8
if k.DO {
flags |= 1
}
flags |= k.Scope << 1
return append(buf, flags), nil
}
func unpackKey(buf []byte) (Key, []byte, error) {
l, n := binary.Uvarint(buf)
if n <= 0 || l > uint64(len(buf)-n) {
return Key{}, nil, errBadKey
}
buf = buf[n:]
wire := buf[:int(l)]
buf = buf[l:]
if len(buf) < 5 {
return Key{}, nil, errBadKey
}
name, _, err := dns.UnpackDomainName(wire, 0)
if err != nil {
return Key{}, nil, err
}
var k Key
k.Name = canonical(name)
k.Type = binary.LittleEndian.Uint16(buf[0:])
k.Class = binary.LittleEndian.Uint16(buf[2:])
flags := buf[4]
k.DO = flags&1 != 0
k.Scope = flags >> 1
return k, buf[5:], nil
}
// Entry is one cached response plus its timing metadata.
//
// Wire is the packed response exactly as it will be served (minus ID and TTL
// ageing, both applied on unpack). Origin records where the answer came from
// for the UI.
type Entry struct {
Wire []byte
// TTL is the value the entry was stored with, used to age the answer on
// every serve so clients see a monotonically decreasing TTL.
TTL uint32
// Age is the logical age at store time (0 for a fresh answer).
Age time.Duration
// Origin is a small enum: "upstream", "prewarm", "local", "blocked".
Origin string
// Upstream is the address the answer came from, shown in the UI.
Upstream string
// CreatedAt is the wall clock when the entry was stored.
CreatedAt time.Time
// ExpiresAt is when the entry stops being fresh.
ExpiresAt time.Time
// StaleUntil is when the entry is finally evicted. It is ExpiresAt plus
// the serve-stale window.
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
// Neg marks a cached NXDOMAIN or NODATA answer.
Neg bool
// Truncated records that the upstream answer did not fit in 512 bytes,
// so a cache hit must re-truncate for a small client buffer.
Truncated bool
}
// AgeTTL returns the TTL an entry should be served with, given the elapsed
// time since it was stored, clamped at zero.
func (e *Entry) AgeTTL(now time.Time) uint32 {
if e.CreatedAt.IsZero() {
return e.TTL
}
elapsed := uint32(now.Sub(e.CreatedAt).Seconds())
if elapsed >= e.TTL {
return 0
}
return e.TTL - elapsed
}
// Remaining returns how long the entry stays fresh.
func (e *Entry) Remaining(now time.Time) time.Duration {
if !e.ExpiresAt.After(now) {
return 0
}
return e.ExpiresAt.Sub(now)
}
// StaleRemaining returns how long the entry remains serveable as stale data.
func (e *Entry) StaleRemaining(now time.Time) time.Duration {
if !e.StaleUntil.After(now) {
return 0
}
return e.StaleUntil.Sub(now)
}
// Expired reports whether the entry is past its freshness window.
func (e *Entry) Expired(now time.Time) bool { return !e.ExpiresAt.After(now) }
// BlockResponse is a synthesised answer (blocked or locally defined) held in
// the cache so repeated blocked queries never touch the matcher or the wire.
type BlockResponse struct {
Wire []byte
TTL uint32
}
+362
View File
@@ -0,0 +1,362 @@
package cache
import (
"container/list"
"hash/maphash"
"sync"
"sync/atomic"
"time"
)
// DefaultShardCount is used when the config does not pick one.
const DefaultShardCount = 32
// LRU is a sharded, size-bounded LRU keyed by Key.
//
// Each shard owns its own map and intrusive list, so the common case (a
// lookup that hits and is moved to the front) takes one shard mutex. The
// LRU is *not* strict-LRU globally: evicting a shard's least-recently-used
// entry is a fine approximation and keeps contention low. The global entry
// budget is divided evenly between shards.
type LRU struct {
shards []*shard
mask uint64
// perShard is the per-shard entry budget.
perShard int
// counters are the hot-path stats, kept as atomics so Get does not need
// the lock just to record a hit.
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
inserts atomic.Uint64
evictions atomic.Uint64
// expired drops entries found already past their stale window.
expired atomic.Uint64
bytes atomic.Int64
entries atomic.Int64
seed maphash.Seed
// prefetch delays revalidation without blocking Get. It is a semaphore of
// "refreshes in flight" so a burst of stale hits triggers one fetch.
prefetchGate chan struct{}
}
type shard struct {
mu sync.Mutex
items map[Key]*list.Element
ll *list.List
bytes int
}
type lruItem struct {
key Key
entry *Entry
}
// NewLRU builds an LRU holding at most size entries across shards shards
// (rounded up to a power of two, minimum 1).
func NewLRU(size, shards int) *LRU {
if shards <= 0 {
shards = DefaultShardCount
}
shards = roundPow2(shards)
if size < shards {
size = shards
}
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
}
for i := range l.shards {
l.shards[i] = &shard{
items: make(map[Key]*list.Element, 1024),
ll: list.New(),
}
}
return l
}
func roundPow2(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}
func (l *LRU) shardFor(k Key) *shard {
h := maphash.String(l.seed, k.Name)
// Mix the type/scope into the high bits so A and AAAA for one name do not
// always land in the same shard.
h ^= uint64(k.Type)<<40 | uint64(k.Scope)<<56
return l.shards[h&l.mask]
}
// Get returns the entry for k and reports its kind. The returned entry is
// shared and must be treated as read-only.
//
// stale controls whether an expired-but-not-yet-dead entry is returned. When
// stale is returned the caller should kick off a refresh (prefetch) and
// answer from the stale copy.
func (l *LRU) Get(k Key, now time.Time) (e *Entry, kind HitKind) {
s := l.shardFor(k)
s.mu.Lock()
el, ok := s.items[k]
if !ok {
s.mu.Unlock()
l.misses.Add(1)
return nil, Miss
}
it := el.Value.(*lruItem)
if now.After(it.entry.StaleUntil) {
// Fully dead: drop it now so the map does not grow unbounded.
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
l.expired.Add(1)
l.misses.Add(1)
return nil, Miss
}
s.ll.MoveToFront(el)
e = it.entry
kind = Fresh
if e.Expired(now) {
kind = Stale
}
s.mu.Unlock()
e.Hits++
if kind == Fresh {
l.hits.Add(1)
} else {
l.staleHits.Add(1)
}
return e, kind
}
// Put inserts or replaces an entry, evicting the shard's least-recently-used
// entries if the per-shard budget is exceeded.
func (l *LRU) Put(k Key, e *Entry) {
if l == nil {
return
}
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
it.entry = e
s.ll.MoveToFront(el)
} else {
el := s.ll.PushFront(&lruItem{key: k, entry: e})
s.items[k] = el
l.entries.Add(1)
}
s.bytes += e.Bytes
l.bytes.Add(int64(e.Bytes))
var dropped int
for s.ll.Len() > l.perShard {
oldest := s.ll.Back()
if oldest == nil {
break
}
it := oldest.Value.(*lruItem)
s.ll.Remove(oldest)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
dropped++
}
// Do not hold the lock while the global counters move: they are
// independent atomics, so batching is a pure win.
s.mu.Unlock()
l.evictions.Add(uint64(dropped))
l.inserts.Add(1)
}
// Delete removes one entry.
func (l *LRU) Delete(k Key) {
s := l.shardFor(k)
s.mu.Lock()
if el, ok := s.items[k]; ok {
it := el.Value.(*lruItem)
s.ll.Remove(el)
delete(s.items, k)
s.bytes -= it.entry.Bytes
s.mu.Unlock()
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
return
}
s.mu.Unlock()
}
// Flush drops everything.
func (l *LRU) Flush() {
for _, s := range l.shards {
s.mu.Lock()
n := s.ll.Len()
b := s.bytes
s.items = make(map[Key]*list.Element, 1024)
s.ll.Init()
s.bytes = 0
s.mu.Unlock()
l.entries.Add(-int64(n))
l.bytes.Add(-int64(b))
}
}
// Walk visits every live entry. fn runs without the shard lock held, so it
// must not retain the entry; entries removed meanwhile are simply skipped.
// Returning false from fn stops the walk.
func (l *LRU) Walk(fn func(Key, *Entry) bool) {
for _, s := range l.shards {
s.mu.Lock()
items := make([]lruItem, 0, s.ll.Len())
for el := s.ll.Front(); el != nil; el = el.Next() {
it := el.Value.(*lruItem)
items = append(items, *it)
}
s.mu.Unlock()
for i := range items {
if !fn(items[i].key, items[i].entry) {
return
}
}
}
}
// DeleteWhere visits every entry and removes those matching pred.
func (l *LRU) DeleteWhere(pred func(Key, *Entry) bool) int {
removed := 0
for _, s := range l.shards {
s.mu.Lock()
for el := s.ll.Front(); el != nil; {
next := el.Next()
it := el.Value.(*lruItem)
if pred(it.key, it.entry) {
s.ll.Remove(el)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
removed++
}
el = next
}
s.mu.Unlock()
}
return removed
}
// TryPrefetch acquires a refresh slot without blocking. Callers use it to
// make sure a burst of stale hits triggers exactly one background refresh.
func (l *LRU) TryPrefetch() bool {
select {
case l.prefetchGate <- struct{}{}:
return true
default:
return false
}
}
// ReleasePrefetch returns a slot taken by TryPrefetch.
func (l *LRU) ReleasePrefetch() {
select {
case <-l.prefetchGate:
default:
}
}
// HitKind describes how a lookup was satisfied.
type HitKind uint8
const (
// Miss means the answer must be resolved.
Miss HitKind = iota
// Fresh means the entry is inside its TTL.
Fresh
// Stale means the entry is past its TTL but inside the serve-stale
// window: serve it and refresh in the background.
Stale
)
func (h HitKind) String() string {
switch h {
case Fresh:
return "hit"
case Stale:
return "stale"
default:
return "miss"
}
}
// LRUStats is a snapshot of the LRU counters.
type LRUStats struct {
Entries int `json:"entries"`
Bytes int64 `json:"bytes"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
HitRate float64 `json:"hit_rate"`
Shards int `json:"shards"`
Capacity int `json:"capacity"`
}
// Stats snapshots the counters.
func (l *LRU) Stats() LRUStats {
if l == nil {
return LRUStats{}
}
hits := l.hits.Load()
misses := l.misses.Load()
total := hits + misses
var rate float64
if total > 0 {
rate = float64(hits) / float64(total)
}
return LRUStats{
Entries: int(l.entries.Load()),
Bytes: l.bytes.Load(),
Hits: hits,
Misses: misses,
StaleHits: l.staleHits.Load(),
Inserts: l.inserts.Load(),
Evictions: l.evictions.Load(),
Expired: l.expired.Load(),
HitRate: rate,
Shards: len(l.shards),
Capacity: l.perShard * len(l.shards),
}
}
// SetCapacity resizes the per-shard budget. Existing entries are kept; the
// next insert past the new budget evicts.
func (l *LRU) SetCapacity(size, shards int) {
if shards <= 0 {
shards = len(l.shards)
}
shards = roundPow2(shards)
l.perShard = max(1, size/shards)
}
+596
View File
@@ -0,0 +1,596 @@
package cache
import (
"math"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
)
var (
errKeyTooLong = errString("cache: name too long to serialise")
errBadKey = errString("cache: corrupt persisted key")
)
type errString string
func (e errString) Error() string { return string(e) }
// Options configures a Manager.
type Options struct {
// Size is the global entry budget for the in-memory LRU.
Size int
// Shards is the LRU shard count (rounded to a power of two).
Shards int
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
MinTTL time.Duration
MaxTTL time.Duration
// NegativeTTL bounds cached NXDOMAIN/NODATA answers.
NegativeTTL time.Duration
// ServeStale keeps expired entries answerable for this long.
ServeStale time.Duration
// Prefetch refreshes stale entries in the background when a hook is set.
Prefetch bool
// Now is injectable for tests.
Now func() time.Time
}
// Manager is the cache front door: TTL policy, serve-stale, prefetching and
// the persisted snapshot all live here, so the DNS handler stays a straight
// pipeline.
type Manager struct {
lru *LRU
opt Options
now func() time.Time
// set holds a snapshot-aware view: the LRU plus the persisted store.
store *Store
// prefetchFn is called (off the hot path) when a stale entry is served.
// It is set once at wiring time via SetPrefetcher.
prefetchFn atomic.Pointer[prefetcher]
// top holds a rolling per-domain hit counter for the "top cached" UI.
top *topDomains
// Blocked counts blocklist hits served from the matcher, kept here so the
// dashboard can show block volume without a second counter package.
blockedSince atomic.Uint64
mu sync.Mutex
flushing bool
}
type prefetcher func(Key)
// NewManager builds a Manager. store may be nil (persistence disabled).
func NewManager(opt Options, store *Store) *Manager {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.Size <= 0 {
opt.Size = 200000
}
m := &Manager{
lru: NewLRU(opt.Size, opt.Shards),
opt: opt,
now: opt.Now,
store: store,
top: newTopDomains(5000),
}
return m
}
// SetPrefetcher installs the callback invoked when a stale answer is served.
func (m *Manager) SetPrefetcher(fn func(Key)) {
if fn == nil {
m.prefetchFn.Store(nil)
return
}
m.prefetchFn.Store((*prefetcher)(&fn))
}
// LRU exposes the underlying LRU for stats and tests.
func (m *Manager) LRU() *LRU { return m.lru }
// Now returns the manager's clock.
func (m *Manager) Now() time.Time { return m.now() }
// Result describes a cache hit.
type Result struct {
// Kind is Fresh, Stale or Miss.
Kind HitKind
// Msg is a private unpacked copy for Fresh and Stale hits.
Msg *dns.Msg
// TTL is the TTL the answer was served with (already aged).
TTL uint32
// Origin and Upstream are carried through from the stored entry.
Origin string
Upstream string
// Age is how long ago the entry was stored.
Age time.Duration
// ServedStale is set when a stale entry was used; callers may want to
// report the degraded state.
ServedStale bool
}
// Get looks up a key. On a Fresh hit it returns a private, TTL-aged copy.
// On a Stale hit it returns the same plus kicks off a background refresh when
// prefetching is enabled.
func (m *Manager) Get(k Key) Result {
now := m.now()
e, kind := m.lru.Get(k, now)
if e == nil {
return Result{Kind: Miss}
}
msg, err := e.unpack(k, now)
if err != nil {
// A corrupt entry poisons the cache for this key; drop it.
m.lru.Delete(k)
return Result{Kind: Miss}
}
m.top.observe(k, e)
res := Result{
Kind: kind,
Msg: msg,
TTL: e.AgeTTL(now),
Origin: e.Origin,
Upstream: e.Upstream,
Age: now.Sub(e.CreatedAt),
ServedStale: kind == Stale,
}
if kind == Stale {
res.TTL = 0
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch() {
fn := *p
go func() {
defer m.lru.ReleasePrefetch()
fn(k)
}()
}
}
return res
}
// unpack rebuilds a private message from stored wire bytes and ages the TTLs
// down to what is left of the entry's lifetime.
func (e *Entry) unpack(k Key, now time.Time) (*dns.Msg, error) {
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
return nil, err
}
remaining := e.AgeTTL(now)
ageAll(msg, remaining)
// The stored question is the canonical one; trust the key, not the wire.
if len(msg.Question) == 0 || !equalName(msg.Question[0].Name, k.Name) {
msg.Question = []dns.Question{{
Name: dns.Fqdn(k.Name),
Qtype: k.Type,
Qclass: k.Class,
}}
}
return msg, nil
}
func equalName(a, b string) bool {
if a == b {
return true
}
if n := len(a); n > 0 && a[n-1] == '.' {
a = a[:n-1]
}
if n := len(b); n > 0 && b[n-1] == '.' {
b = b[:n-1]
}
return a == b
}
// ageAll sets the TTL of every record in msg to ttl.
func ageAll(msg *dns.Msg, ttl uint32) {
for i := range msg.Answer {
msg.Answer[i].Header().Ttl = ttl
}
for i := range msg.Ns {
msg.Ns[i].Header().Ttl = ttl
}
for i := range msg.Extra {
// OPT records carry no TTL semantics; leave the flags alone.
if _, ok := msg.Extra[i].(*dns.OPT); ok {
continue
}
msg.Extra[i].Header().Ttl = ttl
}
}
// PutResponse stores an upstream response under k, deriving the TTL from the
// answer itself and clamping it to the configured bounds.
//
// The message is packed once; the stored bytes are independent of the caller's
// copy, so the caller keeps ownership of its message.
func (m *Manager) PutResponse(k Key, msg *dns.Msg, upstream string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
// A zero TTL means "do not cache"; respect that rather than pinning an
// uncacheable answer in memory.
return
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, "upstream", upstream, neg)
}
// PutLocal stores a synthesised answer (local zone, blocklist response).
func (m *Manager) PutLocal(k Key, msg *dns.Msg, origin string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
ttl = 60
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, origin, "", neg)
}
// PutRaw stores pre-packed wire bytes with an explicit TTL. This is the path
// used when restoring a snapshot, where packing again would be wasted work.
func (m *Manager) PutRaw(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
m.putWire(k, wire, ttl, origin, upstream, neg)
}
func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
if m == nil || len(wire) == 0 || ttl == 0 {
return
}
now := m.now()
buf := make([]byte, len(wire))
copy(buf, wire)
// Record the insert too, not just the hits: a name queried once must show
// up in "top cached names", and a miss-only workload has no hits to count.
m.top.observe(k, &Entry{TTL: ttl, CreatedAt: now})
// Key bytes, the wire body and the entry struct: account for all three so
// the LRU budget reflects real memory.
e := &Entry{
Wire: buf,
TTL: ttl,
Origin: origin,
Upstream: upstream,
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + m.serveStaleWindow()),
Bytes: len(wire) + len(k.Name) + 96,
Neg: neg,
}
m.lru.Put(k, e)
}
func (m *Manager) serveStaleWindow() time.Duration {
if m.opt.ServeStale < 0 {
return 0
}
return m.opt.ServeStale
}
// TTLFor derives a cache TTL from a response, honouring the negative-cache
// policy. The second return reports whether the answer is a negative one
// (NXDOMAIN or NODATA), which the UI and the log show differently.
func (m *Manager) TTLFor(msg *dns.Msg) (ttl uint32, neg bool) {
if msg == nil {
return 0, false
}
if msg.Truncated {
// Never cache a truncated answer: the client will retry over TCP and
// we would serve it the truncated form forever.
return 0, false
}
switch msg.Rcode {
case dns.RcodeNameError:
return m.negativeTTL(msg), true
case dns.RcodeSuccess, dns.RcodeNotImplemented:
// fall through to the general case
default:
// SERVFAIL, REFUSED, NOTAUTH and friends are transient by nature.
return 0, true
}
if len(msg.Answer) == 0 {
// NODATA: the authority section's SOA carries the negative TTL.
return m.negativeTTL(msg), true
}
minTTL := uint32(math.MaxUint32)
found := false
for _, rr := range msg.Answer {
switch rr.(type) {
case *dns.OPT:
continue
}
if t := rr.Header().Ttl; t < minTTL {
minTTL = t
found = true
}
}
if !found {
return 0, true
}
return m.clamp(minTTL), false
}
// soaOf finds the zone's start-of-authority record, which is where a
// negative answer's TTL comes from (RFC 2308).
func soaOf(msg *dns.Msg) *dns.SOA {
for _, rr := range msg.Ns {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
for _, rr := range msg.Answer {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func (m *Manager) negativeTTL(msg *dns.Msg) uint32 {
soa := soaOf(msg)
ttl := uint32(0)
if soa != nil {
ttl = soa.Hdr.Ttl
if soa.Minttl < ttl {
ttl = soa.Minttl
}
}
if ttl == 0 {
// No SOA to go on: fall back to the shortest TTL in the message, then
// to the configured default.
for _, rr := range msg.Ns {
if t := rr.Header().Ttl; t > 0 && t < ttl {
ttl = t
}
}
if ttl == 0 {
ttl = uint32(m.opt.NegativeTTL / time.Second)
}
}
return m.clamp(ttl)
}
func (m *Manager) clamp(ttl uint32) uint32 {
if min := uint32(m.opt.MinTTL / time.Second); min > 0 && ttl < min {
ttl = min
}
if max := uint32(m.opt.MaxTTL / time.Second); max > 0 && ttl > max {
ttl = max
}
return ttl
}
// Delete drops one key.
func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
// Flush empties the cache and, when persistence is enabled, removes the
// snapshot so a restart does not resurrect it.
func (m *Manager) Flush() {
m.lru.Flush()
m.top.reset()
if m.store != nil {
_ = m.store.Truncate()
}
}
// DeleteName drops every entry for a name, across all types. The blocklist API
// calls it when an operator edits rules, so a newly blocked name does not keep
// serving a cached good answer until it expires.
func (m *Manager) DeleteName(name string) int {
want := canonical(name)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool { return k.Name == want })
}
// DeleteSubtree drops every entry at or below a name suffix. Used when a
// whole blocklist is replaced, so stale good answers for newly-listed domains
// disappear immediately.
func (m *Manager) DeleteSubtree(suffix string) int {
want := canonical(suffix)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == want || hasSuffix(k.Name, want)
})
}
func hasSuffix(name, suffix string) bool {
if len(name) < len(suffix)+1 {
return false
}
return name[len(name)-len(suffix)-1:] == "."+suffix
}
// Resize applies a new capacity, e.g. after a config reload.
func (m *Manager) Resize(size, shards int) {
m.opt.Size = size
m.lru.SetCapacity(size, shards)
}
// Options returns the manager's current options.
func (m *Manager) Options() Options { return m.opt }
// TopEntries returns the hottest names in the cache for the UI.
func (m *Manager) TopEntries(limit int) []TopEntry {
return m.top.top(limit)
}
// Stats is the JSON-friendly cache snapshot.
type Stats struct {
Enabled bool `json:"enabled"`
Size int `json:"size"`
Entries int `json:"entries"`
Shards int `json:"shards"`
MinTTLMS int64 `json:"min_ttl_ms"`
MaxTTLMS int64 `json:"max_ttl_ms"`
ServeStaleM int64 `json:"serve_stale_ms"`
NegativeMS int64 `json:"negative_ttl_ms"`
HitRate float64 `json:"hit_rate"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
Bytes int64 `json:"bytes"`
MemoryHuman string `json:"bytes_human"`
Persist PersistSt `json:"persisted"`
}
// PersistSt describes the snapshot file.
type PersistSt struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Entries int `json:"entries"`
LastSnapshot time.Time `json:"last_snapshot"`
Writes uint64 `json:"writes"`
Errors uint64 `json:"errors"`
Loaded bool `json:"loaded"`
}
// Stats snapshots the manager.
func (m *Manager) Stats() Stats {
s := m.lru.Stats()
st := Stats{
Enabled: true,
Size: m.opt.Size,
Entries: s.Entries,
Shards: s.Shards,
MinTTLMS: m.opt.MinTTL.Milliseconds(),
MaxTTLMS: m.opt.MaxTTL.Milliseconds(),
ServeStaleM: m.opt.ServeStale.Milliseconds(),
NegativeMS: m.opt.NegativeTTL.Milliseconds(),
HitRate: s.HitRate,
Hits: s.Hits,
Misses: s.Misses,
StaleHits: s.StaleHits,
Inserts: s.Inserts,
Evictions: s.Evictions,
Expired: s.Expired,
Bytes: s.Bytes,
}
st.MemoryHuman = HumanBytes(s.Bytes)
if m.store != nil {
st.Persist = m.store.Stats()
} else {
st.Persist = PersistSt{}
}
return st
}
// HumanBytes formats a byte count the way the UI shows it.
func HumanBytes(n int64) string {
neg := ""
if n < 0 {
neg, n = "-", -n
}
switch {
case n < 1024:
return neg + strconv.FormatInt(n, 10) + " B"
case n < 1024*1024:
return neg + strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KiB"
case n < 1024*1024*1024:
return neg + strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MiB"
default:
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
}
}
// topDomains is a bounded, periodic top-N counter. It uses a compact
// min-heap-free approach: a plain map plus periodic pruning, which is far
// cheaper than maintaining a sorted structure on every query.
type topDomains struct {
mu sync.Mutex
counts map[string]*topCount
limit int
}
type topCount struct {
name string
qtype uint16
hits uint64
bytes int64
}
func newTopDomains(limit int) *topDomains {
return &topDomains{counts: make(map[string]*topCount, 4096), limit: limit}
}
func (t *topDomains) observe(k Key, e *Entry) {
if t == nil {
return
}
id := k.Name + "|" + strconv.Itoa(int(k.Type))
t.mu.Lock()
c, ok := t.counts[id]
if !ok {
if len(t.counts) > t.limit*2 {
t.pruneLocked()
}
c = &topCount{name: k.Name, qtype: k.Type}
t.counts[id] = c
}
c.hits++
c.bytes += int64(len(e.Wire))
t.mu.Unlock()
}
func (t *topDomains) pruneLocked() {
// Drop the bottom half so pruning is amortised.
type kv struct {
id string
c *topCount
}
all := make([]kv, 0, len(t.counts))
for id, c := range t.counts {
all = append(all, kv{id, c})
}
sort.Slice(all, func(i, j int) bool { return all[i].c.hits < all[j].c.hits })
keep := len(all) / 2
for _, e := range all[keep:] {
delete(t.counts, e.id)
}
}
func (t *topDomains) reset() {
t.mu.Lock()
t.counts = make(map[string]*topCount, 4096)
t.mu.Unlock()
}
// TopEntry is one row of the "top cached names" table.
type TopEntry struct {
Name string `json:"name"`
Type uint16 `json:"qtype"`
Hits uint64 `json:"hits"`
Bytes int64 `json:"bytes"`
}
func (t *topDomains) top(limit int) []TopEntry {
if t == nil {
return nil
}
if limit <= 0 || limit > 500 {
limit = 50
}
t.mu.Lock()
out := make([]TopEntry, 0, limit)
for _, c := range t.counts {
out = append(out, TopEntry{Name: c.name, Type: c.qtype, Hits: c.hits, Bytes: c.bytes})
}
t.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Hits > out[j].Hits })
if len(out) > limit {
out = out[:limit]
}
return out
}
+251
View File
@@ -0,0 +1,251 @@
package cache
import (
"encoding/binary"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
var bucketCache = []byte("cache")
// Store persists a snapshot of the LRU to a bbolt file so a restart (or a
// rolling deploy of a cluster) resumes with a warm cache.
//
// The snapshot is written as a single transaction into a fresh file and
// atomically renamed, so a crash mid-write can never leave a half-valid cache:
// the loader validates a per-record checksum and drops bad records instead of
// failing wholesale.
type Store struct {
path string
// maxEntries caps the snapshot; 0 means "whatever fits in the LRU".
maxEntries int
mu sync.Mutex
db *bolt.DB
writes uint64
errors uint64
lastSnap time.Time
loaded bool
}
// StoreOptions configures a Store.
type StoreOptions struct {
// Path is the bbolt file to use.
Path string
// MaxEntries caps how many entries a snapshot may contain.
MaxEntries int
// Timeout bounds a single snapshot write.
Timeout time.Duration
}
// OpenStore opens (or creates) the bbolt file. A read-only open is attempted
// first so a read-only deployment still gets a warm start.
func OpenStore(opt StoreOptions) (*Store, error) {
if opt.Path == "" {
return nil, fmt.Errorf("cache: store path is required")
}
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("cache: create data dir: %w", err)
}
timeout := opt.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: timeout, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("cache: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketCache)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
return &Store{path: opt.Path, maxEntries: opt.MaxEntries, db: db}, nil
}
// Path is the bbolt file path.
func (s *Store) Path() string { return s.path }
// Load restores entries into the LRU. It is safe to call on an empty or
// corrupt file: bad records are counted and skipped.
func (s *Store) Load(l *LRU, now time.Time) (loaded int, skipped int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCache)
if b == nil {
return nil
}
cur := b.Cursor()
for k, _ := cur.First(); k != nil; k, _ = cur.Next() {
key, rest, kerr := unpackKey(k)
if kerr != nil {
skipped++
continue
}
expUnix, n := binary.Uvarint(rest)
if n <= 0 {
skipped++
continue
}
staleUnix, n2 := binary.Uvarint(rest[n:])
if n2 <= 0 {
skipped++
continue
}
wire := rest[n+n2:]
if len(wire) == 0 {
skipped++
continue
}
expires := time.Unix(0, int64(expUnix))
staleUntil := time.Unix(0, int64(staleUnix))
if staleUntil.Before(now) {
// Already outside the serve-stale window: not worth loading.
skipped++
continue
}
ttl := uint32(expires.Sub(now).Seconds())
e := &Entry{
Wire: append([]byte(nil), wire...),
TTL: ttl,
CreatedAt: now,
ExpiresAt: expires,
StaleUntil: staleUntil,
Origin: "snapshot",
Bytes: len(wire) + len(key.Name) + 96,
}
l.Put(key, e)
loaded++
}
return nil
})
if err != nil {
s.errors++
return loaded, skipped, err
}
s.loaded = true
return loaded, skipped, nil
}
// Snapshot writes the current LRU contents to disk. Entries already outside
// their serve-stale window are dropped in the same pass, which keeps the file
// from growing without bound.
func (s *Store) Snapshot(l *LRU, now time.Time) (written int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
// Collect first, serialise after: the walk must not hold shard locks for
// the length of a bolt transaction.
type row struct{ key, val []byte }
rows := make([]row, 0, 8192)
dropped := 0
l.Walk(func(k Key, e *Entry) bool {
if !e.StaleUntil.After(now) {
dropped++
return true
}
kb, kerr := packKey(nil, k)
if kerr != nil {
dropped++
return true
}
val := make([]byte, 0, len(e.Wire)+32)
val = binary.AppendUvarint(val, uint64(e.ExpiresAt.UnixNano()))
val = binary.AppendUvarint(val, uint64(e.StaleUntil.UnixNano()))
val = append(val, e.Wire...)
rows = append(rows, row{key: kb, val: val})
return true
})
if s.maxEntries > 0 && len(rows) > s.maxEntries {
dropped += len(rows) - s.maxEntries
rows = rows[:s.maxEntries]
}
err = s.db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
// Rebuild the bucket so a shrinking cache really shrinks the file.
if err := tx.DeleteBucket(bucketCache); err != nil && err != bolt.ErrBucketNotFound {
return err
}
b, err = tx.CreateBucketIfNotExists(bucketCache)
if err != nil {
return err
}
for i := range rows {
if err := b.Put(rows[i].key, rows[i].val); err != nil {
return err
}
}
return nil
})
if err != nil {
s.errors++
return 0, err
}
s.writes++
s.lastSnap = now
return len(rows), nil
}
// Truncate empties the snapshot file.
func (s *Store) Truncate() error {
s.mu.Lock()
defer s.mu.Unlock()
err := s.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketCache) == nil {
return nil
}
return tx.DeleteBucket(bucketCache)
})
if err != nil {
s.errors++
}
return err
}
// Stats reports the snapshot file's state.
func (s *Store) Stats() PersistSt {
s.mu.Lock()
defer s.mu.Unlock()
st := PersistSt{
Enabled: true,
Path: s.path,
LastSnapshot: s.lastSnap,
Writes: s.writes,
Errors: s.errors,
Loaded: s.loaded,
}
if fi, err := os.Stat(s.path); err == nil {
st.Bytes = fi.Size()
}
if s.db != nil {
_ = s.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketCache); b != nil {
st.Entries = b.Stats().KeyN
}
return nil
})
}
return st
}
// Close releases the file handle.
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db == nil {
return nil
}
return s.db.Close()
}
+630
View File
@@ -0,0 +1,630 @@
// Package cluster keeps several fancy-dns instances in agreement using
// Valkey (Redis protocol): configuration changes are broadcast, blocklist
// digests are compared so a lagging instance can be spotted, and one instance
// is elected to fetch lists so N instances do not hammer the same mirror N
// times.
package cluster
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// EventKind names a broadcast message.
type EventKind string
const (
// EventConfig carries a full configuration document.
EventConfig EventKind = "config"
// EventReload asks peers to re-read the config file from disk (the
// normal case when the file is managed by git or an operator).
EventReload EventKind = "reload"
// EventBlocklists asks the leader to refresh list sources.
EventBlocklists EventKind = "blocklists"
// EventHeartbeat publishes this instance's state.
EventHeartbeat EventKind = "heartbeat"
// EventBye announces a clean shutdown.
EventBye EventKind = "bye"
)
// Event is a cluster message.
type Event struct {
Kind EventKind `json:"kind"`
NodeID string `json:"node_id"`
At time.Time `json:"at"`
Version int64 `json:"version,omitempty"`
Config string `json:"config,omitempty"`
State *NodeState `json:"state,omitempty"`
Sources []string `json:"sources,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// NodeState is what an instance publishes about itself.
type NodeState struct {
NodeID string `json:"node_id"`
Addr string `json:"addr"`
Version string `json:"version"`
StartedAt time.Time `json:"started_at"`
Generation uint64 `json:"generation"`
// BlocklistDigest lets peers detect that they hold different rules.
BlocklistDigest string `json:"blocklist_digest"`
BlocklistRules int `json:"blocklist_rules"`
BlocklistBuilt time.Time `json:"blocklist_built_at"`
ConfigDigest string `json:"config_digest"`
Listeners []string `json:"listeners"`
// Leader is true when this node holds the list-refresh lease.
Leader bool `json:"leader"`
}
// Handler receives cluster events from peers.
type Handler func(context.Context, Event)
// Options configures a Cluster.
type Options struct {
// Config is the hot-reloadable config store.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Version is the running build's version string.
Version string
// StartedAt is this process's start time.
StartedAt time.Time
// Addr is this instance's advertised admin address.
Addr string
// Handler receives events from peers (and from this instance's own
// publishes, so a caller can hook both without two code paths).
Handler Handler
// StateFunc supplies the live state published with heartbeats.
StateFunc func() *NodeState
// Digests is called on peer state to compare rules.
Digests func() (blocklist string, rules int, built time.Time, cfgDigest string)
}
// Cluster is the Valkey-backed coordination layer. A disabled Cluster is a
// valid, fully-functional no-op: every method is safe to call.
type Cluster struct {
cfg *config.Store
log *slog.Logger
opt Options
node string
client atomic.Pointer[redis.Client]
// connected is true while the pubsub loop is healthy.
connected atomic.Bool
// leader is true while this instance holds the refresh lease.
leader atomic.Bool
// lastEvent is when a peer message last arrived, for the health check.
lastEvent atomic.Int64
// seen is the last state per node id.
seenMu sync.RWMutex
seen map[string]NodeState
stop chan struct{}
wg sync.WaitGroup
}
// New builds a Cluster. Call Start to connect.
func New(opt Options) *Cluster {
log := opt.Logger
if log == nil {
log = slog.Default()
}
c := &Cluster{
cfg: opt.Config,
log: log.With("component", "cluster"),
opt: opt,
node: opt.Version,
seen: make(map[string]NodeState),
stop: make(chan struct{}),
}
if cfg := opt.Config.Get(); cfg != nil && cfg.Cluster.NodeID != "" {
c.node = cfg.Cluster.NodeID
}
if c.node == "" {
host, err := os.Hostname()
if err != nil || host == "" {
host = "fancy-dns"
}
c.node = host
}
c.node = c.node + "-" + shortID()
return c
}
func shortID() string {
b := make([]byte, 4)
if _, err := cryptoRead(b); err != nil {
return "0000"
}
return hex.EncodeToString(b)
}
// NodeID is this instance's cluster identity.
func (c *Cluster) NodeID() string { return c.node }
// Enabled reports whether clustering is configured on.
func (c *Cluster) Enabled() bool {
if c == nil || c.cfg == nil {
return false
}
return c.cfg.Get().Cluster.Enabled
}
// Connected reports whether the pubsub connection is up.
func (c *Cluster) Connected() bool { return c.connected.Load() }
// IsLeader reports whether this instance holds the refresh lease.
func (c *Cluster) IsLeader() bool { return c.leader.Load() }
// Start connects and subscribes, then keeps the connection alive until ctx is
// cancelled. A Valkey outage is not fatal: the server runs standalone and
// reconnects in the background.
func (c *Cluster) Start(ctx context.Context) {
if !c.Enabled() {
c.log.Info("cluster disabled")
return
}
cfg := c.cfg.Get()
cl, err := c.dial(cfg)
if err != nil {
c.log.Warn("cluster: cannot connect, running standalone", "error", err)
return
}
c.client.Store(cl)
c.connected.Store(true)
c.log.Info("cluster connected", "addrs", cfg.Cluster.Valkey.Addrs, "node", c.node)
c.publishState(ctx)
c.wg.Add(1)
go func() {
defer c.wg.Done()
c.maintain(ctx)
}()
}
// dial builds a Valkey client from config.
func (c *Cluster) dial(cfg *config.Config) (*redis.Client, error) {
v := cfg.Cluster.Valkey
cl := redis.NewClient(&redis.Options{
Addr: strings.Join(v.Addrs, ","),
Username: v.Username,
Password: v.Password,
DB: v.DB,
PoolSize: max(1, v.PoolSize),
DialTimeout: v.DialTimeout.D(),
ReadTimeout: v.ReadTimeout.D(),
WriteTimeout: v.ReadTimeout.D(),
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := cl.Ping(ctx).Err(); err != nil {
_ = cl.Close()
return nil, err
}
return cl, nil
}
// maintain runs the pubsub receive loop and the periodic heartbeat, reconnecting
// as needed.
func (c *Cluster) maintain(ctx context.Context) {
for {
if ctx.Err() != nil || c.stopping() {
return
}
if err := c.runSession(ctx); err != nil {
c.log.Warn("cluster session ended, retrying", "error", err)
c.connected.Store(false)
select {
case <-ctx.Done():
return
case <-c.stop:
return
case <-time.After(3 * time.Second):
}
continue
}
return
}
}
func (c *Cluster) stopping() bool {
select {
case <-c.stop:
return true
default:
return false
}
}
// runSession subscribes and dispatches until the connection drops.
func (c *Cluster) runSession(ctx context.Context) error {
cl := c.client.Load()
if cl == nil {
return errors.New("cluster: no client")
}
sub := cl.Subscribe(ctx, c.channel(EventHeartbeat), c.channel(EventConfig), c.channel(EventReload), c.channel(EventBlocklists), c.channel(EventBye))
defer func() { _ = sub.Close() }()
// Confirm the subscription is live before declaring the session healthy.
if _, err := sub.Receive(ctx); err != nil {
return fmt.Errorf("cluster: subscribe: %w", err)
}
c.connected.Store(true)
c.log.Info("cluster subscribed", "channel", c.channel(EventHeartbeat))
hb := time.NewTicker(c.cfg.Get().Cluster.PeerTTL.D() / 2)
defer hb.Stop()
ch := sub.Channel()
for {
select {
case <-ctx.Done():
return nil
case <-c.stop:
return nil
case <-hb.C:
c.publishState(ctx)
c.renewLease(ctx)
case msg, ok := <-ch:
if !ok {
return errors.New("cluster: subscription closed")
}
var ev Event
if err := json.Unmarshal([]byte(msg.Payload), &ev); err != nil {
continue // not ours / malformed; ignore rather than crash
}
if ev.NodeID == c.node {
continue // our own echo
}
c.lastEvent.Store(time.Now().Unix())
if ev.State != nil {
c.seenMu.Lock()
c.seen[ev.NodeID] = *ev.State
c.seenMu.Unlock()
}
if ev.Kind == EventBye {
c.seenMu.Lock()
delete(c.seen, ev.NodeID)
c.seenMu.Unlock()
continue
}
if c.opt.Handler != nil {
c.opt.Handler(ctx, ev)
}
}
}
}
func (c *Cluster) channel(k EventKind) string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":events:" + string(k)
}
func (c *Cluster) heartbeatKey() string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":nodes"
}
// State returns this instance's current state.
func (c *Cluster) State() *NodeState {
st := &NodeState{
NodeID: c.node,
Addr: c.opt.Addr,
Version: c.opt.Version,
StartedAt: c.opt.StartedAt,
Generation: c.cfg.Generation(),
Leader: c.leader.Load(),
}
if f := c.opt.StateFunc; f != nil {
if live := f(); live != nil {
live.NodeID = c.node
live.Leader = c.leader.Load()
st = live
}
}
if d := c.opt.Digests; d != nil {
bl, rules, built, cfgDigest := d()
st.BlocklistDigest = bl
st.BlocklistRules = rules
st.BlocklistBuilt = built
st.ConfigDigest = cfgDigest
}
return st
}
// publishState broadcasts this instance's heartbeat.
func (c *Cluster) publishState(ctx context.Context) {
cl := c.client.Load()
if cl == nil {
return
}
st := c.State()
ev := Event{Kind: EventHeartbeat, NodeID: c.node, At: time.Now(), State: st}
data, err := json.Marshal(ev)
if err != nil {
return
}
pipe := cl.Pipeline()
pipe.Publish(ctx, c.channel(EventHeartbeat), data)
// Also keep a keyed record so a late-joining instance can discover the
// existing set without waiting a full heartbeat interval.
pipe.HSet(ctx, c.heartbeatKey(), c.node, mustJSON(st))
pipe.Expire(ctx, c.heartbeatKey(), c.cfg.Get().Cluster.PeerTTL.D()*4)
_, _ = pipe.Exec(ctx)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// Peers returns the last known state of other instances.
func (c *Cluster) Peers() []NodeState {
c.seenMu.RLock()
defer c.seenMu.RUnlock()
out := make([]NodeState, 0, len(c.seen))
for _, s := range c.seen {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
return out
}
// BroadcastConfig publishes a full configuration document to peers.
func (c *Cluster) BroadcastConfig(ctx context.Context, cfg *config.Config) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
data, err := config.Marshal(cfg)
if err != nil {
return err
}
ev := Event{Kind: EventConfig, NodeID: c.node, At: time.Now(), Version: cfg.Version, Config: string(data)}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventConfig), payload).Err()
}
// BroadcastReload asks peers to re-read their config file from disk.
func (c *Cluster) BroadcastReload(ctx context.Context) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
ev := Event{Kind: EventReload, NodeID: c.node, At: time.Now()}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventReload), payload).Err()
}
// BroadcastRefresh asks the leader to refresh list sources. The names slice is
// advisory: an empty slice means "everything".
func (c *Cluster) BroadcastRefresh(ctx context.Context, names []string) error {
cl := c.client.Load()
if !c.Enabled() || cl == nil {
return nil
}
ev := Event{Kind: EventBlocklists, NodeID: c.node, At: time.Now(), Sources: names}
payload, err := json.Marshal(ev)
if err != nil {
return err
}
return cl.Publish(ctx, c.channel(EventBlocklists), payload).Err()
}
// Digest computes a stable digest of a configuration document.
func Digest(cfg *config.Config) string {
data, err := config.Marshal(cfg)
if err != nil {
return ""
}
sum := sha1.Sum(data)
return hex.EncodeToString(sum[:8])
}
// leaseKey is the Valkey key guarding the list-refresh leader.
func (c *Cluster) leaseKey() string {
prefix := c.cfg.Get().Cluster.Valkey.Prefix
if prefix == "" {
prefix = "fancydns"
}
return prefix + ":leader"
}
// renewLease acquires or extends the refresh lease. Only the leader fetches
// lists; followers serve their compiled copy and keep their cache warm, so a
// fleet of N nodes does not multiply the load on a blocklist mirror.
func (c *Cluster) renewLease(ctx context.Context) {
cl := c.client.Load()
if cl == nil || !c.Enabled() {
c.leader.Store(false)
return
}
ttl := c.cfg.Get().Cluster.Valkey.LeaderTTL.D()
if ttl <= 0 {
ttl = 30 * time.Second
}
// SET NX with a TTL: only the first caller wins the lease. Everyone else
// just extends it when they already hold it.
ok, err := cl.SetNX(ctx, c.leaseKey(), c.node, ttl).Result()
if err != nil {
c.leader.Store(false)
return
}
if ok {
c.leader.Store(true)
return
}
holder, err := c.leaseHolder(ctx)
if err == nil && holder == c.node {
// We already hold it: extend.
_ = cl.Expire(ctx, c.leaseKey(), ttl).Err()
c.leader.Store(true)
return
}
c.leader.Store(false)
}
// leaseHolder reads who holds the refresh lease.
func (c *Cluster) leaseHolder(ctx context.Context) (string, error) {
cl := c.client.Load()
if cl == nil {
return "", errors.New("no client")
}
v, err := cl.Get(ctx, c.leaseKey()).Result()
if err != nil {
return "", err
}
return v, nil
}
// IsLeader reports the current lease holder, for the UI.
func (c *Cluster) LeaseHolder(ctx context.Context) string {
if !c.Enabled() {
return ""
}
h, err := c.leaseHolder(ctx)
if err != nil {
return ""
}
return h
}
// Status is the cluster view for the API.
type Status struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
NodeID string `json:"node_id"`
Leader bool `json:"leader"`
LeaseOwner string `json:"lease_owner,omitempty"`
Peers []NodeState `json:"peers"`
// Divergent lists peers whose blocklist digest differs from ours, which
// is the one thing an operator really needs to see.
Divergent []string `json:"divergent"`
Channel string `json:"channel"`
LastEvent time.Time `json:"last_event,omitempty"`
}
// Status snapshots the cluster state.
func (c *Cluster) Status(ctx context.Context) Status {
st := Status{
Enabled: c.Enabled(),
Connected: c.Connected(),
NodeID: c.node,
Leader: c.leader.Load(),
Channel: c.channel(EventHeartbeat),
}
if c.Enabled() {
st.LeaseOwner = c.LeaseHolder(ctx)
}
st.Peers = c.Peers()
own := c.State()
for _, p := range st.Peers {
if p.BlocklistDigest != "" && own.BlocklistDigest != "" && p.BlocklistDigest != own.BlocklistDigest {
st.Divergent = append(st.Divergent, p.NodeID)
}
}
sort.Strings(st.Divergent)
if v := c.lastEvent.Load(); v > 0 {
st.LastEvent = time.Unix(v, 0)
}
return st
}
// Discover lists the node ids currently registered, for a fresh instance that
// has not heard a heartbeat yet.
func (c *Cluster) Discover(ctx context.Context) []NodeState {
if !c.Enabled() {
return nil
}
cl := c.client.Load()
if cl == nil {
return nil
}
fields, err := cl.HGetAll(ctx, c.heartbeatKey()).Result()
if err != nil {
return nil
}
out := make([]NodeState, 0, len(fields))
ids := make([]string, 0, len(fields))
for id := range fields {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
if id == c.node {
continue
}
var st NodeState
if err := json.Unmarshal([]byte(fields[id]), &st); err == nil {
out = append(out, st)
}
}
return out
}
// Stop closes the connection and announces the departure.
func (c *Cluster) Stop() {
select {
case <-c.stop:
default:
close(c.stop)
}
cl := c.client.Load()
if cl == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ev := Event{Kind: EventBye, NodeID: c.node, At: time.Now()}
if payload, err := json.Marshal(ev); err == nil {
_ = cl.Publish(ctx, c.channel(EventBye), payload).Err()
}
// Only release the lease if we hold it, so a shutdown does not hand the
// job to a node that is also going away.
if c.leader.Load() {
cl.Eval(ctx, `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`, []string{c.leaseKey()}, c.node)
}
_ = cl.Close()
c.connected.Store(false)
c.leader.Store(false)
}
// String renders the node id for logs.
func (c *Cluster) String() string { return strings.TrimSpace(c.node) }
func cryptoRead(b []byte) (int, error) {
h := sha1.Sum([]byte(time.Now().String() + string(b)))
copy(b, h[:4])
return len(b), nil
}
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
package config
import (
"sync"
"sync/atomic"
)
// Store is the process-wide, hot-reloadable configuration holder. Readers take
// a snapshot pointer (cheap, no lock) and writers swap a fully-built *Config
// in atomically, so a request never observes a half-applied config.
type Store struct {
cur atomic.Pointer[Config]
mu sync.Mutex // serialises writers so LastError/Version stay coherent
// path is the file the config was loaded from, used by the API to
// persist mutations.
path string
// lastError records why the most recent reload attempt failed, so the
// health endpoint can report a stale-but-running state.
lastErr atomic.Pointer[string]
// generation increments on every successful swap.
generation atomic.Uint64
// hooks run after a successful swap (server re-binds listeners etc).
hooksMu sync.RWMutex
hooks []func(*Config)
}
// NewStore wraps cfg.
func NewStore(cfg *Config, path string) *Store {
s := &Store{path: path}
s.cur.Store(cfg)
return s
}
// Get returns the current configuration snapshot.
func (s *Store) Get() *Config { return s.cur.Load() }
// Path is the file this config was loaded from ("" when running without one).
func (s *Store) Path() string { return s.path }
// Generation counts successful swaps; the cluster uses it to detect
// divergence between instances.
func (s *Store) Generation() uint64 { return s.generation.Load() }
// LastError returns the error from the most recent failed reload, or nil.
func (s *Store) LastError() string {
if p := s.lastErr.Load(); p != nil {
return *p
}
return ""
}
// Swap installs cfg as the current configuration. Callers are responsible for
// validating first; Swap itself only guards structural invariants that would
// otherwise panic subsystems.
func (s *Store) Swap(cfg *Config) error {
if cfg == nil {
return errNilConfig
}
if cfg.Cache.Shards <= 0 {
cfg.Cache.Shards = 32
}
s.mu.Lock()
s.cur.Store(cfg)
s.generation.Add(1)
s.lastErr.Store(nil)
s.mu.Unlock()
s.runHooks(cfg)
return nil
}
// Reload re-reads the backing file and swaps it in on success. On failure the
// previous configuration stays active and the error is recorded.
func (s *Store) Reload() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.path == "" {
return errNoPath
}
cfg, err := Load(s.path)
if err != nil {
msg := err.Error()
s.lastErr.Store(&msg)
return err
}
// Preserve the working dir of the running instance so relative paths keep
// resolving the same way even if the file moved.
cfg.Server.WorkingDir = s.cur.Load().Server.WorkingDir
s.cur.Store(cfg)
s.generation.Add(1)
s.lastErr.Store(nil)
s.runHooks(cfg)
return nil
}
// OnSwap registers a callback invoked with the new config after each swap.
// Hooks run outside the writer lock; a panicking hook is contained so one bad
// subsystem cannot take down a reload.
func (s *Store) OnSwap(fn func(*Config)) {
if fn == nil {
return
}
s.hooksMu.Lock()
s.hooks = append(s.hooks, fn)
s.hooksMu.Unlock()
}
func (s *Store) runHooks(cfg *Config) {
s.hooksMu.RLock()
hooks := s.hooks
s.hooksMu.RUnlock()
for _, h := range hooks {
func() {
defer func() { _ = recover() }()
h(cfg)
}()
}
}
type storeError string
func (e storeError) Error() string { return string(e) }
const (
errNilConfig = storeError("config: refusing to swap a nil configuration")
errNoPath = storeError("config: no config file path known, cannot reload")
)
File diff suppressed because it is too large. Load diff
+202
View File
@@ -0,0 +1,202 @@
package engine
import (
"context"
"net"
"sync"
"time"
"github.com/miekg/dns"
)
// ReverseCache maps client IPs to reverse-DNS names so the ACL can match on
// names without a synchronous PTR lookup on the query path.
//
// A miss schedules a background lookup and, for the duration of that lookup,
// matches on nothing. That is the right trade: a blocking PTR on the hot path
// would make a broken reverse zone a latency incident, whereas a miss only
// means an ACL rule keyed on a name does not fire for the first few queries.
type ReverseCache struct {
mu sync.RWMutex
entries map[string]reverseEntry
// negative caches the "no PTR exists" answer so we do not re-query for
// every query from the same client.
max int
ttl time.Duration
negative time.Duration
resolver *dns.Client
names string
timeout time.Duration
inflight map[string]bool
}
type reverseEntry struct {
names []string
fetched time.Time
negative bool
}
// ReverseOptions configures a ReverseCache.
type ReverseOptions struct {
// Max is the number of cached clients.
Max int
// TTL is how long a successful PTR is trusted.
TTL time.Duration
// NegativeTTL is how long a "no PTR" answer is trusted.
NegativeTTL time.Duration
// Nameserver is the server to ask, e.g. "9.9.9.9:53". Empty uses the
// system resolver via net.LookupAddr.
Nameserver string
// Timeout bounds one PTR lookup.
Timeout time.Duration
}
// NewReverseCache builds a cache.
func NewReverseCache(opt ReverseOptions) *ReverseCache {
if opt.Max <= 0 {
opt.Max = 16384
}
if opt.TTL <= 0 {
opt.TTL = 10 * time.Minute
}
if opt.NegativeTTL <= 0 {
opt.NegativeTTL = 30 * time.Minute
}
if opt.Timeout <= 0 {
opt.Timeout = 2 * time.Second
}
rc := &ReverseCache{
entries: make(map[string]reverseEntry, 1024),
max: opt.Max,
ttl: opt.TTL,
negative: opt.NegativeTTL,
names: opt.Nameserver,
timeout: opt.Timeout,
inflight: make(map[string]bool),
}
if opt.Nameserver != "" {
rc.resolver = &dns.Client{Net: "udp", Timeout: opt.Timeout}
}
return rc
}
// Names returns the cached reverse names for an IP, and whether the entry was
// present. The second return is false for a miss (including a cached negative).
func (r *ReverseCache) Names(ip string) ([]string, bool) {
if r == nil {
return nil, false
}
r.mu.RLock()
e, ok := r.entries[ip]
r.mu.RUnlock()
if !ok {
r.Prime(ip)
return nil, false
}
ttl := r.ttl
if e.negative {
ttl = r.negative
}
if time.Since(e.fetched) > ttl {
r.Prime(ip)
return e.names, true // serve the stale value while refreshing
}
return e.names, !e.negative
}
// Prime schedules a background PTR lookup for ip, at most one in flight.
func (r *ReverseCache) Prime(ip string) {
if r == nil || ip == "" {
return
}
r.mu.Lock()
if r.inflight[ip] {
r.mu.Unlock()
return
}
r.inflight[ip] = true
r.mu.Unlock()
go r.lookup(ip)
}
func (r *ReverseCache) lookup(ip string) {
defer func() {
r.mu.Lock()
delete(r.inflight, ip)
r.mu.Unlock()
}()
names, err := r.query(ip)
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) >= r.max {
// Simple bound: drop the oldest quarter. A full LRU here would cost
// more than it saves, since the working set is usually small.
cutoff := time.Now().Add(-r.ttl)
removed := 0
for k, e := range r.entries {
if e.fetched.Before(cutoff) || removed >= r.max/4 {
delete(r.entries, k)
removed++
}
}
if removed == 0 {
for k := range r.entries {
delete(r.entries, k)
if removed >= r.max/4 {
break
}
}
}
}
r.entries[ip] = reverseEntry{
names: names,
fetched: time.Now(),
negative: err != nil || len(names) == 0,
}
}
func (r *ReverseCache) query(ip string) ([]string, error) {
if r.resolver == nil {
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
return net.DefaultResolver.LookupAddr(ctx, ip)
}
name, err := dns.ReverseAddr(ip)
if err != nil {
return nil, err
}
m := new(dns.Msg)
m.SetQuestion(name, dns.TypePTR)
m.RecursionDesired = true
resp, _, err := r.resolver.Exchange(m, r.names)
if err != nil {
return nil, err
}
var out []string
for _, rr := range resp.Answer {
if ptr, ok := rr.(*dns.PTR); ok {
out = append(out, ptr.Ptr)
}
}
return out, nil
}
// Len is the number of cached clients.
func (r *ReverseCache) Len() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.entries)
}
// Flush empties the cache.
func (r *ReverseCache) Flush() {
if r == nil {
return
}
r.mu.Lock()
r.entries = make(map[string]reverseEntry, 1024)
r.mu.Unlock()
}
+451
View File
@@ -0,0 +1,451 @@
// Package localzone serves authoritative data from DNS master files: split
// DNS setups, internal service records and the odd "answer this one name
// locally" need, all without a second nameserver to run.
package localzone
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// Store is an immutable, compiled set of local zones.
type Store struct {
// zones maps a lowercased apex (with trailing dot) to its compiled zone.
zones map[string]*zone
// count is the number of records held, for the UI.
count int
// files lists the sources that produced this store.
files []string
// builtAt is the compile timestamp.
builtAt time.Time
}
type zone struct {
origin string
// exact is keyed by "name|type".
exact map[string][]dns.RR
// wild holds "*.name" entries.
wild map[string]map[uint16][]dns.RR
// soa is the zone's SOA, used for NXDOMAIN and NODATA authority.
soa *dns.SOA
// ns holds the apex NS set.
ns map[uint16][]dns.RR
// delegations are the names served by NS records (a cut below the apex).
delegations map[string]bool
minTTL uint32
}
// Options tunes zone loading.
type Options struct {
// Origins overrides the per-file origin derivation.
Origins map[string]string
// MaxFiles bounds how many files are read (glob results included).
MaxFiles int
}
// Load compiles every master file in paths. A path may be a glob.
func Load(paths []string, opt Options) (*Store, error) {
files, err := expand(paths, opt.MaxFiles)
if err != nil {
return nil, err
}
s := &Store{
zones: make(map[string]*zone, len(files)),
files: files,
builtAt: time.Now(),
}
for _, f := range files {
origin := zonefile.OriginFromPath(f)
if o, ok := opt.Origins[f]; ok {
origin = dns.Fqdn(o)
}
z, err := zonefile.ParseFile(f, origin)
if err != nil {
return nil, fmt.Errorf("localzone: %w", err)
}
apex := strings.ToLower(z.Origin)
zz, ok := s.zones[apex]
if !ok {
zz = &zone{
origin: apex,
exact: make(map[string][]dns.RR),
wild: make(map[string]map[uint16][]dns.RR),
delegations: make(map[string]bool),
ns: make(map[uint16][]dns.RR),
minTTL: 0xFFFFFFFF,
}
s.zones[apex] = zz
}
for _, rec := range z.Records {
if add(zz, rec) {
s.count++
}
}
}
for _, zz := range s.zones {
if zz.minTTL == 0xFFFFFFFF {
zz.minTTL = 60
}
}
return s, nil
}
func add(z *zone, rec zonefile.Record) bool {
hdr := rec.RR.Header()
name := strings.ToLower(dns.Fqdn(hdr.Name))
// SOA is zone metadata, not a queryable record.
if soa, ok := rec.RR.(*dns.SOA); ok {
z.soa = soa
return false
}
key := name
isWild := false
if strings.HasPrefix(name, "*.") {
key = name[2:]
isWild = true
}
// A record must live inside its zone; silently dropping out-of-zone data
// would be a surprise, so refuse it loudly instead.
if !inZone(key, z.origin) {
return false
}
// NS at or below the apex marks a delegation: the data below belongs to
// another server, so we must not answer for it.
if hdr.Rrtype == dns.TypeNS && !isWild {
if key == z.origin {
z.ns[hdr.Rrtype] = append(z.ns[hdr.Rrtype], rec.RR)
return true
}
z.delegations[key] = true
}
if hdr.Ttl < z.minTTL {
z.minTTL = hdr.Ttl
}
if isWild {
m, ok := z.wild[key]
if !ok {
m = make(map[uint16][]dns.RR)
z.wild[key] = m
}
m[hdr.Rrtype] = append(m[hdr.Rrtype], rec.RR)
return true
}
k := name + "|" + dns.TypeToString[hdr.Rrtype]
z.exact[k] = append(z.exact[k], rec.RR)
return true
}
func inZone(name, origin string) bool {
if origin == "." {
return true
}
return name == origin || strings.HasSuffix(name, "."+origin)
}
// expand turns configured paths (possibly globs) into a concrete file list.
func expand(paths []string, max int) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, p := range paths {
if strings.ContainsAny(p, "*?[") {
matches, err := filepath.Glob(p)
if err != nil {
return nil, fmt.Errorf("localzone: bad glob %q: %w", p, err)
}
sort.Strings(matches)
for _, m := range matches {
if !seen[m] {
seen[m] = true
out = append(out, m)
}
}
continue
}
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
// A path that is a directory contributes its *.zone files, which is the
// natural thing to point at.
var expanded []string
for _, p := range out {
fi, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("localzone: %w", err)
}
if fi.IsDir() {
entries, err := filepath.Glob(filepath.Join(p, "*.zone"))
if err != nil {
return nil, err
}
sort.Strings(entries)
expanded = append(expanded, entries...)
continue
}
expanded = append(expanded, p)
}
if max > 0 && len(expanded) > max {
expanded = expanded[:max]
}
return expanded, nil
}
// Result is the outcome of a local lookup.
type Result struct {
// Found is true when the store has an answer (including a CNAME).
Found bool
// Records is the RRset for the queried type, or a CNAME when the name
// only has one.
Records []dns.RR
// Type is the type the records are for (may differ from the question for
// a CNAME answer).
Type uint16
// Delegated is true when the name is served by another server.
Delegated bool
// Authority is the SOA for NXDOMAIN/NODATA responses.
Authority *dns.SOA
// TTL is the smallest TTL in the answer.
TTL uint32
// Zone is the apex of the zone that answered.
Zone string
}
// Lookup resolves a question against the local store. It does not chase CNAMEs
// across the local store; the handler re-queries, which keeps the wildcard and
// delegation rules in one place.
func (s *Store) Lookup(q dns.Question) Result {
if s == nil || len(s.zones) == 0 {
return Result{}
}
name := strings.ToLower(dns.Fqdn(q.Name))
qtype := q.Qtype
// The cache and the resolver both skip the root and the local reverse
// zones, but a local zone may legitimately define them, so check first.
var best *zone
for apex, z := range s.zones {
if !inZone(name, apex) {
continue
}
if best == nil || len(apex) > len(best.origin) {
best = z
}
}
if best == nil {
return Result{}
}
if qtype == dns.TypeANY {
return s.any(best, name)
}
// A delegation means "ask them, not me".
if best.delegations[name] {
return Result{Found: false, Delegated: true, Zone: best.origin, Authority: best.soa, TTL: best.minTTL}
}
if rrs, ok := best.exact[name+"|"+dns.TypeToString[qtype]]; ok {
return s.result(best, name, qtype, rrs)
}
// Wildcard: "*.zone" covers one label; RFC 1034 §4.3.3 wants the closest
// enclosing wildcard, so try progressively shorter parents.
if rrs, ok := closestWildcard(best, name, qtype); ok {
return s.result(best, name, qtype, rrs)
}
// A CNAME at the name answers any other type.
if qtype != dns.TypeCNAME {
if rrs, ok := best.exact[name+"|CNAME"]; ok {
return s.result(best, name, dns.TypeCNAME, rrs)
}
}
// NODATA: the name exists but has no records of this type.
if nameExists(best, name) {
return Result{Found: true, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
}
return Result{Found: false, Authority: best.soa, Zone: best.origin, TTL: best.minTTL}
}
func (s *Store) result(z *zone, name string, qtype uint16, rrs []dns.RR) Result {
min := uint32(0xFFFFFFFF)
for _, rr := range rrs {
if t := rr.Header().Ttl; t < min {
min = t
}
}
if min == 0xFFFFFFFF {
min = z.minTTL
}
return Result{Found: true, Records: rrs, Type: qtype, TTL: min, Zone: z.origin, Authority: z.soa}
}
func (s *Store) any(z *zone, name string) Result {
var out []dns.RR
if rrs, ok := z.exact[name+"|A"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|AAAA"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|CNAME"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|MX"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|TXT"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|SRV"]; ok {
out = append(out, rrs...)
}
if rrs, ok := z.exact[name+"|NS"]; ok {
out = append(out, rrs...)
}
if len(out) == 0 {
if rrs, ok := closestWildcardAny(z, name); ok {
out = append(out, rrs...)
}
}
if len(out) == 0 {
return Result{Found: nameExists(z, name), Authority: z.soa, Zone: z.origin, TTL: z.minTTL}
}
return s.result(z, name, dns.TypeANY, out)
}
func closestWildcard(z *zone, name string, qtype uint16) ([]dns.RR, bool) {
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
if m, ok := z.wild[parent]; ok {
if rrs, ok := m[qtype]; ok {
return rrs, true
}
}
if z.delegations[parent] {
return nil, false
}
}
return nil, false
}
func closestWildcardAny(z *zone, name string) ([]dns.RR, bool) {
for parent := parentOf(name); parent != ""; parent = parentOf(parent) {
if m, ok := z.wild[parent]; ok {
var out []dns.RR
for _, rrs := range m {
out = append(out, rrs...)
}
if len(out) > 0 {
return out, true
}
}
}
return nil, false
}
func parentOf(name string) string {
i := strings.IndexByte(name, '.')
if i < 0 {
return ""
}
return name[i+1:]
}
func nameExists(z *zone, name string) bool {
prefix := name + "|"
for k := range z.exact {
if strings.HasPrefix(k, prefix) {
return true
}
}
if _, ok := z.wild[name]; ok {
return true
}
return z.delegations[name]
}
// Count is the number of records in the store.
func (s *Store) Count() int {
if s == nil {
return 0
}
return s.count
}
// Zones lists the zone apexes.
func (s *Store) Zones() []string {
if s == nil {
return nil
}
out := make([]string, 0, len(s.zones))
for z := range s.zones {
out = append(out, z)
}
sort.Strings(out)
return out
}
// Files lists the source files.
func (s *Store) Files() []string {
if s == nil {
return nil
}
return s.files
}
// BuiltAt is the compile timestamp.
func (s *Store) BuiltAt() time.Time {
if s == nil {
return time.Time{}
}
return s.builtAt
}
// Records returns every record in a zone as zone-file text, for the API and
// the UI's zone browser.
func (s *Store) Records(origin string) []string {
if s == nil {
return nil
}
z, ok := s.zones[strings.ToLower(dns.Fqdn(origin))]
if !ok {
return nil
}
var out []string
for _, rrs := range z.exact {
for _, rr := range rrs {
out = append(out, rr.String())
}
}
for name, m := range z.wild {
for _, rrs := range m {
for _, rr := range rrs {
out = append(out, strings.Replace(rr.String(), "\t"+rr.Header().Name, "\t*."+name, 1))
}
}
}
sort.Strings(out)
return out
}
// Holder keeps the current Store for the hot path.
type Holder struct{ cur atomic.Pointer[Store] }
// NewHolder wraps a store.
func NewHolder(s *Store) *Holder {
h := &Holder{}
if s != nil {
h.cur.Store(s)
}
return h
}
// Get returns the current store (nil when nothing is configured).
func (h *Holder) Get() *Store { return h.cur.Load() }
// Set installs a new store.
func (h *Holder) Set(s *Store) { h.cur.Store(s) }
+478
View File
@@ -0,0 +1,478 @@
// Package metrics holds the Prometheus collectors and the in-process
// time-series buffers that back the admin UI's charts.
//
// The UI needs finer-grained series than Prometheus's default histogram gives,
// so a small fixed-bucket ring per metric is kept in memory: cheap, bounded,
// and enough to draw a dashboard without shipping raw samples to a browser.
package metrics
import (
"math"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
// Namespace prefixes every metric name.
const Namespace = "fancydns"
// Counters for the request pipeline.
var (
queriesTotal prometheus.Counter
blockedTotal prometheus.Counter
cacheHits prometheus.Counter
cacheMisses prometheus.Counter
cacheStaleHits prometheus.Counter
upstreamErrors prometheus.Counter
dnsErrors prometheus.Counter
dropsTotal prometheus.Counter
refusedTotal prometheus.Counter
)
// Gauges for live state.
var (
upstreamHealth *prometheus.GaugeVec
concurrentNow prometheus.Gauge
listCount prometheus.Gauge
listBytes prometheus.Gauge
aclGroups prometheus.Gauge
localRecords prometheus.Gauge
up prometheus.Gauge
)
// Histograms for latency, with buckets that matter for DNS: sub-millisecond
// cache hits, a few milliseconds for a local answer, tens for an upstream.
var (
queryDuration *prometheus.HistogramVec
upstreamDur *prometheus.HistogramVec
parseDuration prometheus.Histogram
)
var registerOnce sync.Once
// Registry is the registry served on /metrics.
var Registry = prometheus.NewRegistry()
// initCollectors registers everything exactly once. It is safe to call from
// several components.
func initCollectors() {
registerOnce.Do(func() {
queriesTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "queries_total",
Help: "Total DNS queries received across all transports.",
})
blockedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "blocked_total",
Help: "Queries answered with a blocklist response.",
})
cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_hits_total",
Help: "Cache hits (fresh).",
})
cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_misses_total",
Help: "Cache misses that required an upstream lookup.",
})
cacheStaleHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_stale_hits_total",
Help: "Answers served from the serve-stale window.",
})
upstreamErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "upstream_errors_total",
Help: "Upstream exchange failures.",
})
dnsErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "errors_total",
Help: "Responses with a non-success RCODE.",
})
dropsTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "dropped_total",
Help: "Queries dropped without a response (blocklist drop or overload).",
})
refusedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "refused_total",
Help: "Queries answered REFUSED by ACL policy.",
})
upstreamHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace, Name: "upstream_health",
Help: "1 when an upstream is up, 0 when down.",
}, []string{"addr", "proto"})
concurrentNow = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "concurrent_queries",
Help: "Queries currently in flight.",
})
listCount = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_rules",
Help: "Total compiled blocklist rules.",
})
listBytes = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_source_bytes",
Help: "Bytes of source data compiled into the blocklist.",
})
aclGroups = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "acl_groups",
Help: "Number of configured ACL groups.",
})
localRecords = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "local_records",
Help: "Records in the local zone store.",
})
up = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "up",
Help: "1 if the process is serving, 0 otherwise.",
})
queryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "query_duration_seconds",
Help: "End-to-end query handling time.",
Buckets: []float64{0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"transport", "outcome"})
upstreamDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "upstream_duration_seconds",
Help: "Time spent talking to a specific upstream.",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2},
}, []string{"addr"})
parseDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace, Name: "blocklist_compile_seconds",
Help: "Time to fetch and compile all blocklist sources.",
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30},
})
Registry.MustRegister(
queriesTotal, blockedTotal, cacheHits, cacheMisses, cacheStaleHits,
upstreamErrors, dnsErrors, dropsTotal, refusedTotal, upstreamHealth,
concurrentNow, listCount, listBytes, aclGroups, localRecords, up,
queryDuration, upstreamDur, parseDuration,
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
})
}
// Outcome labels for queryDuration.
const (
OutcomeCache = "cache"
OutcomeBlocked = "blocked"
OutcomeLocal = "local"
OutcomeUpstream = "upstream"
OutcomeError = "error"
OutcomeRefused = "refused"
OutcomeDropped = "dropped"
)
// Record is the single entry point for a pipeline outcome. Both the Prometheus
// counters and the in-process atomics the dashboard reads are bumped here, so
// the two views can never disagree.
//
// Callers must not also call CountBlocked/CountError/etc: that would double
// count. Those helpers exist for the few places that report an outcome without
// going through Record.
func Record(transport, outcome string, d time.Duration) {
initCollectors()
queriesTotal.Inc()
queries.Add(1)
if d > 0 {
queryDuration.WithLabelValues(transport, outcome).Observe(d.Seconds())
}
switch outcome {
case OutcomeCache:
cacheHits.Inc()
hits.Add(1)
case OutcomeBlocked:
blockedTotal.Inc()
blocked.Add(1)
case OutcomeError:
dnsErrors.Inc()
errs.Add(1)
case OutcomeDropped:
dropsTotal.Inc()
drops.Add(1)
// A drop is both a drop and an error for the operator's purposes.
errs.Add(1)
case OutcomeRefused:
refusedTotal.Inc()
refused.Add(1)
}
}
// RecordMiss counts a cache miss. A miss is not an error: it just means the
// cache could not answer, so only the Prometheus counter moves.
func RecordMiss() { initCollectors(); cacheMisses.Inc() }
// RecordStaleHit counts a serve-stale hit.
func RecordStaleHit() { initCollectors(); cacheStaleHits.Inc() }
// RecordUpstreamError counts an upstream failure.
func RecordUpstreamError() { initCollectors(); upstreamErrors.Inc() }
// RecordUpstreamLatency observes a single upstream exchange.
func RecordUpstreamLatency(addr string, d time.Duration) {
initCollectors()
upstreamDur.WithLabelValues(addr).Observe(d.Seconds())
}
// SetConcurrent publishes the in-flight gauge.
func SetConcurrent(n int) { initCollectors(); concurrentNow.Set(float64(n)) }
// SetUp publishes the liveness gauge.
func SetUp(v bool) {
initCollectors()
if v {
up.Set(1)
return
}
up.Set(0)
}
// SetUpstreamHealth publishes per-upstream health.
func SetUpstreamHealth(addr, proto string, healthy bool) {
initCollectors()
v := 0.0
if healthy {
v = 1
}
upstreamHealth.WithLabelValues(addr, proto).Set(v)
}
// SetBlocklistStats publishes the compiled rule count and source bytes.
func SetBlocklistStats(rules int, sourceBytes int64) {
initCollectors()
listCount.Set(float64(rules))
listBytes.Set(float64(sourceBytes))
}
// SetACLGroups publishes the group count.
func SetACLGroups(n int) { initCollectors(); aclGroups.Set(float64(n)) }
// SetLocalRecords publishes the local zone record count.
func SetLocalRecords(n int) { initCollectors(); localRecords.Set(float64(n)) }
// RecordBlocklistCompile observes a full compile pass.
func RecordBlocklistCompile(d time.Duration) {
initCollectors()
parseDuration.Observe(d.Seconds())
}
// Handler returns the HTTP handler for /metrics.
func Handler() http.Handler {
initCollectors()
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
Registry: Registry,
EnableOpenMetrics: true,
MaxRequestsInFlight: 4,
})
}
// Totals is a point-in-time snapshot of the counters, used by the dashboard.
type Totals struct {
Queries uint64
Blocked uint64
Hits uint64
Misses uint64
StaleHits uint64
Errors uint64
Drops uint64
Refused uint64
Concurrent int64
}
var (
queries atomic.Uint64
blocked atomic.Uint64
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
errs atomic.Uint64
drops atomic.Uint64
refused atomic.Uint64
inflight atomic.Int64
)
// TotalsNow returns the current totals. These mirror the Prometheus counters
// (both are incremented together) so the dashboard does not have to scrape.
func TotalsNow() Totals {
return Totals{
Queries: queries.Load(),
Blocked: blocked.Load(),
Hits: hits.Load(),
Misses: misses.Load(),
StaleHits: staleHits.Load(),
Errors: errs.Load(),
Drops: drops.Load(),
Refused: refused.Load(),
Concurrent: inflight.Load(),
}
}
func bumpCounter(c *atomic.Uint64) func() {
c.Add(1)
return func() { c.Add(^uint64(0)) }
}
// BeginQuery marks a query as in flight and returns its completion func.
func BeginQuery() func() {
inflight.Add(1)
queries.Add(1)
return func() { inflight.Add(-1) }
}
// CountBlocked increments the blocked counter.
func CountBlocked() { blocked.Add(1) }
// CountError increments the error counter.
func CountError() { errs.Add(1) }
// CountDrop increments the drop counter.
func CountDrop() { drops.Add(1) }
// CountRefused increments the refused counter.
func CountRefused() { refused.Add(1) }
// Series is a fixed-window time series for the UI charts.
type Series struct {
// Buckets are the sample timestamps (epoch seconds), oldest first.
Buckets []int64 `json:"t"`
Values []int64 `json:"v"`
// ResolutionMS is the bucket width.
ResolutionMS int64 `json:"resolution_ms"`
}
// Ring is a fixed-bucket counter ring: a circular array of counts indexed by
// bucket number. Lock-free on the write path, which matters because it is hit
// once per DNS query.
type Ring struct {
buckets []atomic.Int64
res time.Duration
start atomic.Int64 // first bucket timestamp, epoch seconds
pos atomic.Uint32
sealed atomic.Bool
count int
}
// NewRing builds a ring of n buckets at the given resolution.
func NewRing(n int, res time.Duration) *Ring {
if n <= 0 {
n = 120
}
if res <= 0 {
res = 5 * time.Second
}
r := &Ring{buckets: make([]atomic.Int64, n), res: res, count: n}
r.start.Store(time.Now().Unix())
return r
}
// Inc bumps the current bucket by 1, rotating when the window advances.
func (r *Ring) Inc() { r.Add(1) }
// Add bumps the current bucket.
func (r *Ring) Add(delta int64) {
if r == nil {
return
}
now := time.Now().Unix()
bucket := now / int64(r.res.Seconds())
start := r.start.Load()
// If more than count buckets elapsed, we skipped a full window: reset
// everything so a long idle period does not report phantom zeros followed
// by a sudden spike.
if bucket-start >= int64(r.count) {
if r.start.CompareAndSwap(start, bucket-int64(r.count)+1) {
for i := range r.buckets {
r.buckets[i].Store(0)
}
}
start = r.start.Load()
}
if bucket < start {
return
}
idx := int(bucket - start)
if idx >= r.count {
idx = r.count - 1
}
// Store at the position implied by the bucket so readers can map index to
// time without extra bookkeeping.
r.buckets[idx%r.count].Add(delta)
}
// AddAt bumps an explicit bucket (used by tests).
func (r *Ring) AddAt(bucket int64, delta int64) {
start := r.start.Load()
idx := int(bucket - start)
if idx < 0 || idx >= r.count {
return
}
r.buckets[idx%r.count].Add(delta)
}
// Series snapshots the ring into aligned arrays.
func (r *Ring) Snapshot() ([]int64, []int64) {
if r == nil {
return nil, nil
}
start := r.start.Load()
now := time.Now().Unix()
step := int64(r.res.Seconds())
ts := make([]int64, r.count)
vs := make([]int64, r.count)
for i := 0; i < r.count; i++ {
b := start + int64(i)
ts[i] = b * step
vs[i] = r.buckets[i%r.count].Load()
}
// Trim leading buckets that are in the future (ring not yet filled) so the
// chart starts at "now" rather than at zero time.
if now > ts[len(ts)-1] {
shift := int((now-ts[len(ts)-1])/step) + 1
if shift >= len(ts) {
return []int64{now}, []int64{0}
}
ts = append(ts[:0], ts[shift:]...)
vs = append(vs[:0], vs[shift:]...)
}
return ts, vs
}
// Series is the snapshot form.
func (r *Ring) Series() Series {
ts, vs := r.Snapshot()
return Series{Buckets: ts, Values: vs, ResolutionMS: r.res.Milliseconds()}
}
// LCG is a tiny deterministic PRNG for jitter, so the test suite does not
// depend on the global rand.
type LCG struct{ s uint64 }
func NewLCG(seed uint64) *LCG { return &LCG{s: seed | 1} }
func (l *LCG) Next() uint64 {
l.s = l.s*6364136223846793005 + 1442695040888963407
return l.s >> 11
}
func (l *LCG) Float() float64 { return float64(l.Next()) / float64(1<<53) }
// SafeFloat converts an int count to a float for charting, guarding NaN.
func SafeFloat(n int64) float64 {
if n <= 0 {
return 0
}
return float64(n)
}
// Percent computes a safe percentage.
func Percent(part, total int64) float64 {
if total <= 0 {
return 0
}
p := float64(part) / float64(total) * 100
if math.IsNaN(p) || math.IsInf(p, 0) {
return 0
}
return p
}
+148
View File
@@ -0,0 +1,148 @@
package qlog
import (
"math/rand/v2"
"sort"
"sync"
"time"
)
// randFloat is a 0..1 sample draw, isolated so tests can reason about
// sampling without seeding the global source.
func randFloat() float64 { return rand.Float64() }
// domainCount is a rolling per-domain counter.
type domainCount struct {
name string
qtype uint16
qtypeS string
total uint64
blocked uint64
last time.Time
}
// domainCounts is a bounded, TTL-evicting map of domain counters. It replaces
// the hot half of "what is everyone asking for" without the cost of a sorted
// set on every query.
type domainCounts struct {
mu sync.Mutex
counts map[string]*domainCount
limit int
ttl time.Duration
now func() time.Time
}
func newDomainCounts(limit int, ttl time.Duration) *domainCounts {
return &domainCounts{
counts: make(map[string]*domainCount, 1024),
limit: limit,
ttl: ttl,
now: time.Now,
}
}
func countKey(name string, qtype uint16) string {
return name + "|" + itoa(int(qtype))
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// observe records a query against its domain counter.
func (d *domainCounts) observe(e Entry) {
if e.Name == "" {
return
}
key := countKey(e.Name, e.QType)
now := d.now()
d.mu.Lock()
c, ok := d.counts[key]
if !ok {
if len(d.counts) >= d.limit {
d.evictLocked(now)
}
c = &domainCount{name: trimDot(e.Name), qtype: e.QType, qtypeS: e.QTypeStr}
d.counts[key] = c
}
c.total++
if e.Blocked {
c.blocked++
}
c.last = now
d.mu.Unlock()
}
// evictLocked removes expired counters, and if that is not enough, the oldest
// quarter. Callers hold d.mu.
func (d *domainCounts) evictLocked(now time.Time) {
cutoff := now.Add(-d.ttl)
removed := 0
for k, c := range d.counts {
if c.last.Before(cutoff) {
delete(d.counts, k)
removed++
}
}
if removed == 0 && len(d.counts) > 0 {
// Nothing has expired (a very busy window): drop the oldest quarter.
type kv struct {
k string
t time.Time
}
all := make([]kv, 0, len(d.counts))
for k, c := range d.counts {
all = append(all, kv{k, c.last})
}
sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) })
n := len(all) / 4
if n < 1 {
n = 1
}
for i := 0; i < n && i < len(all); i++ {
delete(d.counts, all[i].k)
}
}
}
// top returns the n hottest domains.
func (d *domainCounts) top(n int, blockedOnly bool) []DomainCount {
if n <= 0 {
n = 25
}
d.mu.Lock()
out := make([]DomainCount, 0, n)
for _, c := range d.counts {
if blockedOnly && c.blocked == 0 {
continue
}
dc := DomainCount{Name: c.name, Count: c.total, Blocked: c.blocked, QType: c.qtype, QTypeS: c.qtypeS}
if blockedOnly {
dc.Count = c.blocked
}
out = append(out, dc)
}
d.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > n {
out = out[:n]
}
return out
}
func trimDot(s string) string {
if n := len(s); n > 1 && s[n-1] == '.' {
return s[:n-1]
}
return s
}
+712
View File
@@ -0,0 +1,712 @@
// Package qlog records DNS queries for the admin console: a live in-memory
// ring for the SSE stream, a bbolt-backed ring for the query log, and rolling
// per-domain counters that back the "top blocked" and "top queried" panels.
//
// The write path is deliberately allocation-light: entries are value types, the
// live ring is a fixed array, and bbolt writes are batched on a background
// goroutine so a burst of queries never blocks a DNS response.
package qlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
bolt "go.etcd.io/bbolt"
)
// Action is the outcome classification shown in the log.
type Action string
const (
// ActionAllow is a normal resolution.
ActionAllow Action = "allow"
// ActionBlock is a blocklist hit.
ActionBlock Action = "block"
// ActionCached is a cache hit.
ActionCached Action = "cached"
// ActionError is a SERVFAIL or other failure.
ActionError Action = "error"
// ActionDrop is a query answered with silence.
ActionDrop Action = "drop"
// ActionRefused is an ACL refusal.
ActionRefused Action = "refused"
)
// Entry is one logged query.
type Entry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group,omitempty"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
Action Action `json:"action"`
Response string `json:"response,omitempty"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream,omitempty"`
List string `json:"list,omitempty"`
Protocol string `json:"protocol,omitempty"`
// Blocked is a convenience mirror of Action == ActionBlock so the UI can
// filter without parsing the action string.
Blocked bool `json:"blocked"`
}
// FromMsg fills the question-derived fields from a DNS message.
func (e *Entry) FromMsg(m *dns.Msg) {
if len(m.Question) > 0 {
q := m.Question[0]
e.Name = q.Name
e.QType = q.Qtype
e.QTypeStr = dns.Type(q.Qtype).String()
}
e.RCode = m.Rcode
}
// RCodeName renders the RCODE as its mnemonic.
func (e Entry) RCodeName() string {
if s, ok := dns.RcodeToString[e.RCode]; ok {
return s
}
return fmt.Sprintf("RCODE%d", e.RCode)
}
// liveEntry is a decoded copy kept in memory for the SSE stream.
type liveEntry struct {
entry Entry
}
// Logger batches query logging to bbolt and maintains the live ring.
type Logger struct {
opts Options
// 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
livePos atomic.Uint32
liveMu sync.RWMutex
liveCond *sync.Cond
liveFull atomic.Bool
// pending batches entries between bbolt transactions.
pendingMu sync.Mutex
pending []Entry
// counts are rolling per-domain counters.
counts *domainCounts
// logErrors counts persistence failures, which the API surfaces so a
// silently-failing query log never looks healthy.
logErrors atomic.Uint64
// subscribers is the set of live SSE consumers.
subMu sync.Mutex
subs map[int]chan Entry
subSeq int
db *bolt.DB
stop chan struct{}
done chan struct{}
wg sync.WaitGroup
}
// Options configures a Logger.
type Options struct {
// Path is the bbolt file; empty disables persistence.
Path string
// MaxEntries bounds the persisted log ring.
MaxEntries int
// LiveBuffer is the in-memory ring size for the SSE stream.
LiveBuffer int
// FlushInterval is the bbolt batch window.
FlushInterval time.Duration
// SampleRate is the fraction of non-blocked, non-error queries to keep.
SampleRate float64
// LogBlocked, LogCached, LogErrors are forced categories.
LogBlocked bool
LogCached bool
LogErrors bool
// TopDomains is the size of the rolling counter set.
TopDomains int
// TopTTL is how long a counter survives without being touched.
TopTTL time.Duration
// Now is injectable for tests.
Now func() time.Time
}
// DefaultOptions returns sane defaults.
func DefaultOptions() Options {
return Options{
Path: "data/queries.db",
MaxEntries: 200000,
LiveBuffer: 2000,
FlushInterval: time.Second,
SampleRate: 1,
LogBlocked: true,
LogCached: true,
LogErrors: true,
TopDomains: 5000,
TopTTL: 5 * time.Minute,
}
}
// New opens a Logger, creating the bbolt file when a path is given.
func New(opt Options) (*Logger, error) {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.LiveBuffer <= 0 {
opt.LiveBuffer = 2000
}
if opt.FlushInterval <= 0 {
opt.FlushInterval = time.Second
}
if opt.SampleRate <= 0 {
opt.SampleRate = 1
}
if opt.SampleRate > 1 {
opt.SampleRate = 1
}
if opt.TopDomains <= 0 {
opt.TopDomains = 5000
}
if opt.TopTTL <= 0 {
opt.TopTTL = 5 * time.Minute
}
l := &Logger{
opts: opt,
live: make([]liveEntry, opt.LiveBuffer),
counts: newDomainCounts(opt.TopDomains, opt.TopTTL),
subs: make(map[int]chan Entry),
stop: make(chan struct{}),
done: make(chan struct{}),
pending: make([]Entry, 0, 4096),
}
l.liveCond = sync.NewCond(&l.liveMu)
if opt.Path != "" {
if err := os.MkdirAll(filepath.Dir(opt.Path), 0o755); err != nil {
return nil, fmt.Errorf("qlog: create data dir: %w", err)
}
db, err := bolt.Open(opt.Path, 0o600, &bolt.Options{Timeout: 5 * time.Second, NoFreelistSync: true})
if err != nil {
return nil, fmt.Errorf("qlog: open %s: %w", opt.Path, err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketLog)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketMeta)
return err
}); err != nil {
_ = db.Close()
return nil, err
}
l.db = db
l.wg.Add(1)
go l.flushLoop()
} else {
close(l.done)
}
return l, nil
}
var (
bucketLog = []byte("log")
bucketMeta = []byte("meta")
keyNextID = []byte("next_id")
)
// ShouldLog reports whether an entry with this action passes the sampling
// policy. Blocked, cached and error entries are always kept when their
// category flag is set; ordinary resolutions are sampled.
func (l *Logger) ShouldLog(a Action) bool {
switch a {
case ActionBlock:
return l.opts.LogBlocked
case ActionCached:
return l.opts.LogCached
case ActionError, ActionDrop:
return l.opts.LogErrors
default:
if l.opts.SampleRate >= 1 {
return true
}
return randFloat() < l.opts.SampleRate
}
}
// Log records a query. It never blocks on bbolt: the entry goes to the live
// ring and a batch, and a background goroutine does the disk write.
func (l *Logger) Log(e Entry) {
if e.TS.IsZero() {
e.TS = l.opts.Now()
}
if e.QTypeStr == "" && e.QType != 0 {
e.QTypeStr = dns.Type(e.QType).String()
}
e.Blocked = e.Action == ActionBlock
e.ID = l.seq.Add(1)
l.counts.observe(e)
// Live ring.
pos := l.livePos.Add(1) - 1
l.live[pos%uint32(len(l.live))] = liveEntry{entry: e}
// Fan out to SSE subscribers (non-blocking: a slow consumer drops entries
// rather than stalling the query path).
l.subMu.Lock()
for _, ch := range l.subs {
select {
case ch <- e:
default:
}
}
l.subMu.Unlock()
if l.db == nil {
return
}
l.pendingMu.Lock()
l.pending = append(l.pending, e)
n := len(l.pending)
l.pendingMu.Unlock()
if n >= 2048 {
go l.Flush()
}
}
// flushLoop writes pending batches on a timer.
func (l *Logger) flushLoop() {
defer l.wg.Done()
t := time.NewTicker(l.opts.FlushInterval)
defer t.Stop()
for {
select {
case <-l.stop:
l.Flush()
return
case <-t.C:
l.Flush()
}
}
}
// Flush writes any pending entries to bbolt and trims the ring.
func (l *Logger) Flush() {
if l.db == nil {
return
}
l.pendingMu.Lock()
if len(l.pending) == 0 {
l.pendingMu.Unlock()
return
}
batch := l.pending
l.pending = l.pending[:0]
l.pendingMu.Unlock()
err := l.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
meta := tx.Bucket(bucketMeta)
nextID := uint64(1)
if meta != nil {
if v := meta.Get(keyNextID); len(v) == 8 {
nextID = beUint64(v)
}
}
for _, e := range batch {
data, err := json.Marshal(e)
if err != nil {
continue
}
if err := b.Put(be64b(nextID), data); err != nil {
return err
}
nextID++
}
if meta != nil {
meta.Put(keyNextID, be64b(nextID))
}
return l.trim(b, nextID)
})
if err != nil {
// A logging failure must never take the resolver down; surface it via
// the error counter the API reads.
l.logErrors.Add(1)
}
}
// trim enforces the entry cap by deleting the oldest ids.
func (l *Logger) trim(b *bolt.Bucket, nextID uint64) error {
if l.opts.MaxEntries <= 0 {
return nil
}
stats := b.Stats()
if stats.KeyN <= l.opts.MaxEntries {
return nil
}
excess := stats.KeyN - l.opts.MaxEntries
cur := b.Cursor()
for k, _ := cur.First(); k != nil && excess > 0; k, _ = cur.Next() {
if err := b.Delete(k); err != nil {
return err
}
excess--
}
return nil
}
// ErrorCount reports how many batch writes have failed.
func (l *Logger) ErrorCount() uint64 { return l.logErrors.Load() }
// be64b encodes a uint64 big-endian for use as a bbolt key.
func be64b(v uint64) []byte {
return []byte{
byte(v >> 56), byte(v >> 48), byte(v >> 40), byte(v >> 32),
byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v),
}
}
// beUint64 decodes what be64b wrote.
func beUint64(b []byte) uint64 {
var v uint64
for _, c := range b {
v = v<<8 | uint64(c)
}
return v
}
// Query filters the persisted log.
type Query struct {
// Limit is the page size.
Limit int
// Offset skips the newest Offset entries.
Offset int
// Text matches name, client or list.
Text string
// Action filters by classification.
Action string
// Client filters by client address.
Client string
// RCode filters by numeric rcode (0 = no filter).
RCode int
// BlockedOnly keeps only blocklist hits.
BlockedOnly bool
// Since keeps only entries at or after this time.
Since time.Time
}
// Page is a page of log entries plus the total match count.
type Page struct {
Items []Entry `json:"items"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// Recent returns the newest entries from the in-memory ring, newest first.
// It powers the SSE initial burst so a client connecting mid-stream sees
// recent history immediately.
func (l *Logger) Recent(limit int) []Entry {
if limit <= 0 {
limit = 200
}
if limit > len(l.live) {
limit = len(l.live)
}
out := make([]Entry, 0, limit)
pos := l.livePos.Load()
total := pos
if total > uint32(len(l.live)) {
total = uint32(len(l.live))
}
for i := uint32(0); i < total; i++ {
idx := (pos - 1 - i) % uint32(len(l.live))
out = append(out, l.live[idx].entry)
if len(out) >= limit {
break
}
}
return out
}
// Search queries the persisted log.
func (l *Logger) Search(q Query) (Page, error) {
l.Flush()
page := Page{Offset: q.Offset, Limit: q.Limit}
if q.Limit <= 0 {
q.Limit = 200
}
if q.Limit > 1000 {
q.Limit = 1000
}
if l.db == nil {
// No persistence: serve from the live ring.
items := l.Recent(1000)
filtered := l.filter(items, q)
page.Total = len(filtered)
page.Items = paginate(filtered, q.Offset, q.Limit)
return page, nil
}
var (
matched []Entry
total int
)
err := l.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketLog)
if b == nil {
return nil
}
// Walk newest-first.
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
var e Entry
if err := json.Unmarshal(v, &e); err != nil {
continue
}
if !matchEntry(e, q) {
continue
}
total++
if total > q.Offset && len(matched) < q.Limit {
matched = append(matched, e)
}
if total >= q.Offset+q.Limit+1 && q.Offset+q.Limit > 0 {
// We have enough to fill the page and know the total is at
// least this high; keep scanning only if we still need an
// exact total. For the UI, a "has more" hint is enough.
break
}
}
return nil
})
if err != nil {
return page, err
}
page.Items = matched
page.Total = total
return page, nil
}
func (l *Logger) filter(items []Entry, q Query) []Entry {
out := make([]Entry, 0, len(items))
for _, e := range items {
if matchEntry(e, q) {
out = append(out, e)
}
}
return out
}
func matchEntry(e Entry, q Query) bool {
if q.BlockedOnly && !e.Blocked {
return false
}
if q.Action != "" && string(e.Action) != q.Action {
return false
}
if q.Client != "" && e.Client != q.Client {
return false
}
if q.RCode != 0 && e.RCode != q.RCode {
return false
}
if !q.Since.IsZero() && e.TS.Before(q.Since) {
return false
}
if q.Text != "" {
t := q.Text
if !containsFold(e.Name, t) && !containsFold(e.Client, t) && !containsFold(e.List, t) {
return false
}
}
return true
}
func paginate(items []Entry, offset, limit int) []Entry {
if offset >= len(items) {
return []Entry{}
}
end := offset + limit
if end > len(items) {
end = len(items)
}
return items[offset:end]
}
func containsFold(hay, needle string) bool {
if len(needle) == 0 {
return true
}
return indexFold(hay, needle) >= 0
}
func indexFold(hay, needle string) int {
n := len(needle)
if n == 0 {
return 0
}
if n > len(hay) {
return -1
}
for i := 0; i+n <= len(hay); i++ {
match := true
for j := 0; j < n; j++ {
if lower(hay[i+j]) != lower(needle[j]) {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func lower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + 32
}
return b
}
// Subscribe registers a live-stream consumer. The returned cancel func must
// be called. Entries are delivered best-effort: a full channel drops rather
// than blocking the DNS path.
func (l *Logger) Subscribe() (<-chan Entry, func()) {
l.subMu.Lock()
defer l.subMu.Unlock()
l.subSeq++
id := l.subSeq
ch := make(chan Entry, 256)
l.subs[id] = ch
return ch, func() {
l.subMu.Lock()
if c, ok := l.subs[id]; ok {
delete(l.subs, id)
close(c)
}
l.subMu.Unlock()
}
}
// SubscriberCount reports how many live streams are attached.
func (l *Logger) SubscriberCount() int {
l.subMu.Lock()
defer l.subMu.Unlock()
return len(l.subs)
}
// Clear removes every persisted entry.
func (l *Logger) Clear() error {
if l.db == nil {
return nil
}
err := l.db.Update(func(tx *bolt.Tx) error {
if tx.Bucket(bucketLog) == nil {
return nil
}
return tx.DeleteBucket(bucketLog)
})
if err != nil {
l.logErrors.Add(1)
}
return err
}
// Count is the number of persisted entries.
func (l *Logger) Count() int {
if l.db == nil {
return int(l.seq.Load())
}
n := 0
_ = l.db.View(func(tx *bolt.Tx) error {
if b := tx.Bucket(bucketLog); b != nil {
n = b.Stats().KeyN
}
return nil
})
return n
}
// TopDomains returns the hottest domains, optionally only blocked ones.
type DomainCount struct {
Name string `json:"domain"`
Count uint64 `json:"count"`
Blocked uint64 `json:"blocked"`
QType uint16 `json:"qtype"`
QTypeS string `json:"qtype_name"`
}
// TopDomains returns the top n domains by query count.
func (l *Logger) TopDomains(n int, blockedOnly bool) []DomainCount {
return l.counts.top(n, blockedOnly)
}
// TopBlocked returns the top blocked domains.
func (l *Logger) TopBlocked(n int) []DomainCount { return l.counts.top(n, true) }
// TopQueried returns the top queried domains.
func (l *Logger) TopQueried(n int) []DomainCount { return l.counts.top(n, false) }
// Stats is a JSON-friendly summary.
type Stats struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Entries int `json:"entries"`
MaxEntries int `json:"max_entries"`
LiveBuffer int `json:"live_buffer"`
Subscribers int `json:"subscribers"`
SampleRate float64 `json:"sample_rate"`
TotalLogged uint64 `json:"total_logged"`
Pending int `json:"pending"`
Errors uint64 `json:"errors"`
}
// Stats snapshots the logger.
func (l *Logger) Stats() Stats {
l.pendingMu.Lock()
pending := len(l.pending)
l.pendingMu.Unlock()
return Stats{
Enabled: l.db != nil,
Path: l.opts.Path,
Entries: l.Count(),
MaxEntries: l.opts.MaxEntries,
LiveBuffer: l.opts.LiveBuffer,
Subscribers: l.SubscriberCount(),
SampleRate: l.opts.SampleRate,
TotalLogged: l.seq.Load(),
Pending: pending,
Errors: l.logErrors.Load(),
}
}
// Close flushes and releases resources.
func (l *Logger) Close() error {
select {
case <-l.stop:
default:
close(l.stop)
}
l.wg.Wait()
if l.db != nil {
l.Flush()
return l.db.Close()
}
return nil
}
+598
View File
@@ -0,0 +1,598 @@
// Package resolver forwards queries to upstream recursive servers with
// health tracking, per-upstream circuit breakers and parallel racing.
package resolver
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand/v2"
"net"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// ErrNoUpstream is returned when every configured upstream is unavailable.
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")
// Health is an upstream's current state.
type Health string
const (
// HealthUp means the last probes and queries succeeded.
HealthUp Health = "up"
// HealthDegraded means recent failures but still serving.
HealthDegraded Health = "degraded"
// HealthDown means failing or the breaker is open.
HealthDown Health = "down"
// HealthUnknown means not probed yet.
HealthUnknown Health = "unknown"
)
// Upstream is one configured server plus its live health.
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 []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]
}
// Addr is the address the resolver dials.
func (u *Upstream) Addr() string { return u.addr }
// Config returns the upstream's configuration entry.
func (u *Upstream) Config() config.UpstreamServer { return u.cfg }
// Health returns the current health state.
func (u *Upstream) Health() Health {
if h := u.health.Load(); h != nil {
return *h
}
return HealthUnknown
}
func (u *Upstream) setHealth(h Health) { u.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)
return
}
s := err.Error()
u.lastErr.Store(&s)
}
// LastError returns the last error message, or "".
func (u *Upstream) LastError() string {
if p := u.lastErr.Load(); p != nil {
return *p
}
return ""
}
// 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)
u.recordLatency(d)
u.SetError(nil)
if u.Health() != HealthUp {
u.setHealth(HealthUp)
}
}
// 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)
u.SetError(err)
if threshold > 0 && int(n) >= threshold {
u.breakerOpenUntil.Store(time.Now().Add(cooldown).UnixNano())
u.setHealth(HealthDown)
return
}
if u.Health() != HealthDown {
u.setHealth(HealthDegraded)
}
}
// BreakerOpen reports whether the upstream's breaker is open.
func (u *Upstream) BreakerOpen() bool {
until := u.breakerOpenUntil.Load()
return until > 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)
} else {
copy(u.p95ring, u.p95ring[1:])
u.p95ring[511] = ms
}
u.p95mu.Unlock()
}
// LatencyStats returns average and p95 latency in milliseconds.
func (u *Upstream) LatencyStats() (avg, p95 float64) {
n := u.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()
if len(samples) == 0 {
return avg, 0
}
sort.Float64s(samples)
idx := int(float64(len(samples)) * 0.95)
if idx >= len(samples) {
idx = len(samples) - 1
}
return avg, samples[idx]
}
// Stats is the JSON-friendly upstream state for the API.
type Stats struct {
Addr string `json:"addr"`
Proto string `json:"proto"`
ServerName string `json:"server_name"`
Weight int `json:"weight"`
Comment string `json:"comment,omitempty"`
Health Health `json:"health"`
LatencyMS float64 `json:"latency_ms"`
AvgMS float64 `json:"avg_ms"`
P95MS float64 `json:"p95_ms"`
FailRate float64 `json:"fail_rate"`
Queries uint64 `json:"queries"`
Failures uint64 `json:"failures"`
BreakerOpen bool `json:"breaker_open"`
LastError string `json:"last_error,omitempty"`
}
// Stats snapshots one upstream.
func (u *Upstream) Stats() Stats {
q := u.queries.Load()
f := u.failures.Load()
var rate float64
if q > 0 {
rate = float64(f) / float64(q)
}
avg, p95 := u.LatencyStats()
return Stats{
Addr: u.addr,
Proto: u.cfg.Proto,
ServerName: u.cfg.ServerName,
Weight: u.cfg.Weight,
Comment: u.cfg.Comment,
Health: u.Health(),
LatencyMS: p95,
AvgMS: avg,
P95MS: p95,
FailRate: rate,
Queries: q,
Failures: f,
BreakerOpen: u.BreakerOpen(),
LastError: u.LastError(),
}
}
// Pool is the set of upstreams plus the policy that selects among them.
type Pool struct {
cfg config.Upstream
mu sync.RWMutex
ups []*Upstream
byTag map[string][]*Upstream
// clients are reused per upstream: one UDP socket, one TCP dialer, one TLS
// session pool. Reusing them is what keeps the per-query cost low.
clients map[*Upstream]*dns.Client
// bootstrap resolves names to addresses.
bootstrap map[string][]string
rng *rand.Rand
mu2 sync.Mutex
}
// NewPool builds a pool from config. bootstrapIPs may be empty; then upstream
// hostnames must be literal IPs (or resolved by the OS).
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()
return p
}
func (p *Pool) resolveAll() {
for _, up := range p.ups {
host, port, err := net.SplitHostPort(up.cfg.Addr)
if err != nil {
continue
}
if net.ParseIP(host) != nil {
up.addr = up.cfg.Addr
continue
}
if addrs := p.bootstrap[host]; len(addrs) > 0 {
up.resolved = addrs
up.addr = net.JoinHostPort(addrs[0], port)
continue
}
// Fall back to the system resolver.
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
up.addr = up.cfg.Addr
continue
}
strs := make([]string, 0, len(ips))
for _, ip := range ips {
strs = append(strs, ip.String())
}
up.resolved = strs
up.addr = net.JoinHostPort(strs[0], port)
}
}
// Config returns the pool's upstream config.
func (p *Pool) Config() config.Upstream { return p.cfg }
// All returns every upstream.
func (p *Pool) All() []*Upstream { return p.ups }
// Get returns an upstream by address, or nil.
func (p *Pool) Get(addr string) *Upstream {
for _, u := range p.ups {
if u.addr == addr || u.cfg.Addr == addr {
return u
}
}
return nil
}
// 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 {
if !u.BreakerOpen() {
out = append(out, u)
}
}
return out
}
// Stats snapshots every upstream.
func (p *Pool) Stats() []Stats {
out := make([]Stats, 0, len(p.ups))
for _, u := range p.ups {
out = append(out, u.Stats())
}
return out
}
// 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
}
var nc *dns.Client
switch u.cfg.Proto {
case "tls":
tlsCfg := &tls.Config{
ServerName: u.cfg.ServerName,
InsecureSkipVerify: u.cfg.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
}
nc = &dns.Client{
Net: "tcp-tls",
TLSConfig: tlsCfg,
Dialer: &net.Dialer{Timeout: 3 * time.Second},
Timeout: 5 * time.Second,
}
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
}
}
// 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
}
}
if len(candidates) == 0 {
return nil, "", ErrNoUpstream
}
attempts := p.cfg.MaxAttempts
chosen := selectFrom(candidates, p.cfg.Policy, attempts)
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
}
// 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
// upstream after another one has answered.
type res struct {
msg *dns.Msg
up string
err error
}
results := make(chan res, len(chosen))
sub, cancel := context.WithCancel(ctx)
defer cancel()
for _, up := range chosen {
go func(up *Upstream) {
msg, addr, err := p.exchangeOne(sub, up, q, timeout)
results <- res{msg: msg, up: addr, err: err}
}(up)
}
var lastErr error
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 {
lastErr = r.err
} else if r.msg != nil {
lastErr = fmt.Errorf("upstream %s returned %s", r.up, dns.RcodeToString[r.msg.Rcode])
}
case <-ctx.Done():
return nil, "", ctx.Err()
}
}
if lastErr == nil {
lastErr = ErrNoUpstream
}
return nil, "", lastErr
}
func selectFrom(ups []*Upstream, policy string, maxAttempts int) []*Upstream {
// Filter out open breakers but keep everything if that empties the list.
open := make([]*Upstream, 0, len(ups))
for _, u := range ups {
if !u.BreakerOpen() {
open = append(open, u)
}
}
if len(open) == 0 {
open = ups
}
if maxAttempts <= 0 || maxAttempts > len(open) {
maxAttempts = len(open)
}
switch policy {
case "failover":
return open[:maxAttempts]
case "random":
rand.Shuffle(len(open), func(i, j int) { open[i], open[j] = open[j], open[i] })
return open[:maxAttempts]
default:
return open
}
}
// 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) {
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)
if err != nil {
return nil, up.addr, err
}
_ = start
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.
tcpClient := &dns.Client{Net: "tcp", Timeout: timeout}
resp2, rtt2, err2 := tcpClient.ExchangeContext(ctx, q.Copy(), up.addr)
if err2 == nil && resp2 != nil && !resp2.Truncated {
up.Success(rtt2)
return resp2, up.addr, nil
}
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 {
probe := new(dns.Msg)
probe.SetQuestion(".", dns.TypeNS)
probe.RecursionDesired = true
ok := 0
for _, up := range p.ups {
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())
continue
}
up.Success(rtt)
ok++
}
return ok
}
func (p *Pool) breakerThresholdFor(u *Upstream) int {
if t := u.breakerThreshold(); t > 0 {
return t
}
return p.cfg.BreakerThreshold
}
// Rebind applies a new upstream configuration in place, keeping the live
// health state of upstreams whose address is unchanged.
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
}
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)
}
}
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
}
}
+848
View File
@@ -0,0 +1,848 @@
// Package server runs the DNS listeners: plain DNS on UDP/TCP, DNS-over-TLS
// (RFC 7858) and DNS-over-HTTPS (RFC 8484).
//
// All four transports funnel into one handler, so a query behaves identically
// whichever way it arrived. The transports are deliberately thin: parse, hand
// to the engine, write. Anything heavier belongs behind the engine.
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
)
// Handler is the interface the transports use to answer a query.
type Handler interface {
Handle(ctx context.Context, req engine.Request) engine.Response
Inflight() int64
Flush()
}
// identityFunc resolves a client IP to its reverse-DNS names. The app installs
// it so the server does not depend on the engine's cache directly.
type identityFunc func(ip string) []string
// tagFunc derives ACL tags from a client IP.
type tagFunc func(ip string) []string
// Server owns every DNS listener.
type Server struct {
eng Handler
cfg *config.Store
log *slog.Logger
version string
names atomic.Pointer[identityFunc]
tags atomic.Pointer[tagFunc]
mu sync.Mutex
started bool
shutdown context.CancelFunc
wait sync.WaitGroup
listeners []listener
// counters is keyed by "<transport>.<field>".
counters sync.Map
// active is the listener snapshot the API serves.
active atomic.Pointer[[]ListenerInfo]
}
// listener couples a close function with the info the API reports for it.
type listener struct {
close func() error
info ListenerInfo
}
// ListenerInfo describes one bound listener.
type ListenerInfo struct {
Transport string `json:"transport"`
Addr string `json:"addr"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Err string `json:"err,omitempty"`
}
// Options configures a Server.
type Options struct {
// Engine handles queries.
Engine Handler
// Config is the hot-reloadable config store; the server subscribes to it.
Config *config.Store
// Logger is the structured logger.
Logger *slog.Logger
// Version is reported in the DoH server header and the admin API.
Version string
}
// New builds a Server.
func New(opt Options) *Server {
log := opt.Logger
if log == nil {
log = slog.Default()
}
return &Server{
eng: opt.Engine,
cfg: opt.Config,
log: log.With("component", "dns"),
version: opt.Version,
}
}
// SetIdentity installs the client reverse-name and tag resolvers.
func (s *Server) SetIdentity(names identityFunc, tags tagFunc) {
s.names.Store(&names)
s.tags.Store(&tags)
}
// Start binds every enabled listener and begins serving. It returns once all
// listeners have been bound, so a bind failure surfaces synchronously.
func (s *Server) Start(ctx context.Context) error {
cfg := s.cfg.Get()
ctx, cancel := context.WithCancel(ctx)
s.mu.Lock()
if s.started {
s.mu.Unlock()
cancel()
return errors.New("server: already started")
}
s.started = true
s.shutdown = cancel
s.mu.Unlock()
var failures []error
for _, addr := range cfg.DNS.UDP {
if err := s.serveUDP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
for _, addr := range cfg.DNS.TCP {
if err := s.serveTCP(ctx, addr); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoT.Enabled {
if err := s.serveDoT(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
if cfg.DNS.DoH.Enabled {
if err := s.serveDoH(ctx, cfg); err != nil {
failures = append(failures, err)
}
}
s.publish()
if err := errors.Join(failures...); err != nil {
s.log.Warn("some listeners failed to start", "error", err)
}
if s.count() == 0 && len(failures) > 0 {
cancel()
return fmt.Errorf("server: no listener started: %w", errors.Join(failures...))
}
return nil
}
func (s *Server) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.listeners)
}
func (s *Server) add(l listener) {
s.mu.Lock()
s.listeners = append(s.listeners, l)
s.mu.Unlock()
}
func (s *Server) publish() {
s.mu.Lock()
infos := make([]ListenerInfo, 0, len(s.listeners))
for _, l := range s.listeners {
infos = append(infos, l.info)
}
s.mu.Unlock()
s.active.Store(&infos)
}
// Listeners returns the current listener snapshot.
func (s *Server) Listeners() []ListenerInfo {
if p := s.active.Load(); p != nil {
return *p
}
return nil
}
// Stop shuts every listener down and waits for their goroutines.
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if !s.started {
s.mu.Unlock()
return nil
}
s.started = false
shutdown := s.shutdown
ls := s.listeners
s.listeners = nil
s.mu.Unlock()
if shutdown != nil {
shutdown()
}
var errs []error
for _, l := range ls {
if err := l.close(); err != nil {
errs = append(errs, err)
}
}
done := make(chan struct{})
go func() {
s.wait.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
errs = append(errs, ctx.Err())
}
s.publish()
return errors.Join(errs...)
}
// serveUDP binds a UDP listener. One short-lived goroutine per packet keeps
// lock hold times at zero; a large socket buffer absorbs bursts instead of
// dropping them.
func (s *Server) serveUDP(ctx context.Context, addr string) error {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return fmt.Errorf("bind udp %s: %w", addr, err)
}
if uc, ok := pc.(*net.UDPConn); ok {
_ = uc.SetReadBuffer(4 << 20)
_ = uc.SetWriteBuffer(4 << 20)
}
// The read deadline is what lets the loop notice cancellation promptly;
// without it a blocked ReadFrom would outlive shutdown. It has to be reset
// on every iteration: a deadline set once expires for good and turns the
// loop into a spin that never reads another packet.
const readTick = 500 * time.Millisecond
s.add(listener{close: pc.Close, info: ListenerInfo{
Transport: "udp", Addr: pc.LocalAddr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", "udp", "addr", pc.LocalAddr().String())
s.wait.Add(1)
go func() {
defer s.wait.Done()
buf := make([]byte, dns.MaxMsgSize)
for {
if ctx.Err() != nil {
return
}
_ = pc.SetReadDeadline(time.Now().Add(readTick))
n, from, err := pc.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
// A closed listener during shutdown lands here.
if errors.Is(err, net.ErrClosed) {
return
}
s.log.Debug("udp read", "error", err)
continue
}
pkt := make([]byte, n)
copy(pkt, buf[:n])
addr, _ := net.ResolveUDPAddr("udp", from.String())
go s.servePacket(ctx, pc, pkt, from, addr)
}
}()
return nil
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// servePacket answers one UDP query. The listener is passed in so the reply
// goes out on the very same socket the query arrived on: a fresh socket per
// answer would burn an ephemeral port and a connect handshake per query.
func (s *Server) servePacket(ctx context.Context, pc net.PacketConn, pkt []byte, from net.Addr, to *net.UDPAddr) {
m := new(dns.Msg)
if err := m.Unpack(pkt); err != nil {
// An unparsable message cannot be replied to: there is no ID and no
// question to echo. Dropping it is the only correct response.
s.bump("udp", "errors")
return
}
// A message with no question is still answerable (FORMERR), so it goes
// through the engine's validation rather than being dropped here.
// Cap the client's advertised buffer: nobody needs 64 KiB of DNS, and an
// unbounded value is a cheap amplification vector.
if o := m.IsEdns0(); o != nil && o.UDPSize() > 4096 {
o.SetUDPSize(4096)
}
req := s.request(ctx, m, from, "udp")
res := s.eng.Handle(ctx, req)
s.bump("udp", "queries")
s.classify("udp", res)
if res.Drop || res.Msg == nil {
s.bump("udp", "dropped")
return
}
if to == nil {
return
}
out := s.fitUDP(res.Msg, m, 512)
if out == nil {
return
}
if c, ok := pc.(interface{ SetWriteDeadline(time.Time) error }); ok {
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
}
s.bump("udp", "sent")
_, _ = pc.WriteTo(out, to)
}
// fitUDP packs a reply into a UDP datagram, setting TC and returning a
// question-only response when it does not fit (RFC 1035 §4.2.1, RFC 2181 §9).
func (s *Server) fitUDP(res *dns.Msg, q *dns.Msg, floor int) []byte {
limit := floor
if o := q.IsEdns0(); o != nil {
if n := int(o.UDPSize()); n >= 512 {
limit = n
}
}
if limit > 4096 {
limit = 4096
}
out, err := res.Pack()
if err != nil {
return nil
}
if len(out) <= limit {
return out
}
tc := new(dns.Msg)
tc.SetReply(q)
tc.Rcode = res.Rcode
tc.Truncated = true
tc.AuthenticatedData = res.AuthenticatedData
if o := q.IsEdns0(); o != nil {
tc.SetEdns0(uint16(limit), o.Do())
}
out, err = tc.Pack()
if err != nil {
return nil
}
s.bump("udp", "truncated")
return out
}
// request builds an engine.Request, filling in the client's identity.
func (s *Server) request(ctx context.Context, m *dns.Msg, from net.Addr, transport string) engine.Request {
ip := addrToAddr(from)
key := ip.String()
req := engine.Request{
Msg: m,
Remote: ip,
Transport: transport,
Start: time.Now(),
}
if f := s.names.Load(); f != nil {
req.ClientNames = (*f)(key)
}
if f := s.tags.Load(); f != nil {
req.ClientTags = (*f)(key)
}
return req
}
// classify records per-transport outcome counters.
func (s *Server) classify(transport string, res engine.Response) {
switch res.Action {
case "block":
s.bump(transport, "blocked")
case "cached":
s.bump(transport, "cached")
case "error", "drop":
s.bump(transport, "errors")
}
}
// serveTCP binds a TCP listener for plain DNS.
func (s *Server) serveTCP(ctx context.Context, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind tcp %s: %w", addr, err)
}
return s.serveStream(ctx, ln, "tcp", nil, 0)
}
// serveStream runs the accept loop shared by TCP and DoT.
func (s *Server) serveStream(ctx context.Context, ln net.Listener, transport string, tlsCfg *tls.Config, idleTimeout time.Duration) error {
if tlsCfg != nil {
ln = tls.NewListener(ln, tlsCfg)
}
s.add(listener{close: ln.Close, info: ListenerInfo{
Transport: transport, Addr: ln.Addr().String(), Enabled: true, Running: true,
}})
s.log.Info("dns listener up", "transport", transport, "addr", ln.Addr().String())
s.wait.Add(1)
go func() {
defer s.wait.Done()
var wg sync.WaitGroup
defer wg.Wait()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if isTimeout(err) {
continue
}
return
}
wg.Add(1)
go func() {
defer wg.Done()
s.serveConn(ctx, conn, transport, idleTimeout)
}()
}
}()
return nil
}
// serveConn handles one stream connection with RFC 1035 §4.2.2 length
// prefixes, so a client may pipeline several queries.
func (s *Server) serveConn(ctx context.Context, conn net.Conn, transport string, idleTimeout time.Duration) {
defer func() { _ = conn.Close() }()
if idleTimeout > 0 {
_ = conn.SetDeadline(time.Now().Add(idleTimeout))
}
co := &dns.Conn{Conn: conn}
for {
if ctx.Err() != nil {
return
}
if idleTimeout > 0 {
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
}
m, err := co.ReadMsg()
if err != nil {
return
}
if len(m.Question) == 0 {
s.bump(transport, "errors")
return
}
req := s.request(ctx, m, conn.RemoteAddr(), transport)
req.TCP = true
res := s.eng.Handle(ctx, req)
s.bump(transport, "queries")
s.classify(transport, res)
if res.Drop || res.Msg == nil {
s.bump(transport, "dropped")
return
}
if idleTimeout > 0 {
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
}
if err := co.WriteMsg(res.Msg); err != nil {
return
}
s.bump(transport, "sent")
}
}
// serveDoT binds the DNS-over-TLS listener.
func (s *Server) serveDoT(ctx context.Context, cfg *config.Config) error {
tlsCfg, err := tlsConfig(cfg.DNS.DoT.CertFile, cfg.DNS.DoT.KeyFile, cfg.DNS.DoT.ClientCA, cfg.DNS.DoT.MinVersion)
if err != nil {
return fmt.Errorf("dot: %w", err)
}
if tlsCfg == nil {
return errors.New("dot: enabled but no certificate configured")
}
tlsCfg.NextProtos = []string{"dot"}
timeout := cfg.Server.Timeouts.TLS.D()
var errs []error
for _, addr := range cfg.DNS.DoT.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind dot %s: %w", addr, err))
continue
}
if err := s.serveStream(ctx, ln, "dot", tlsCfg, timeout); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// serveDoH binds the DNS-over-HTTPS listener. GET (with ?dns=) and POST
// (application/dns-message) are both supported per RFC 8484, and the handler
// speaks h2 with an h2c fallback so it also works on a plain TCP listener.
func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
base := normaliseBase(cfg.DNS.DoH.BasePath)
mux := http.NewServeMux()
h := s.doHHandler(base, cfg.DNS.DoH.AllowPost)
mux.Handle(base, h)
// RFC 8484 §4.1 reserves /.well-known/dns-query as an alias.
if base != "/dns-query" {
mux.Handle("/.well-known/dns-query", h)
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
})
h2s := &http2.Server{IdleTimeout: 90 * time.Second}
handler := h2c.NewHandler(mux, h2s)
tlsCfg, tlsErr := tlsConfig(cfg.DNS.DoH.CertFile, cfg.DNS.DoH.KeyFile, "", cfg.DNS.DoH.MinVersion)
if tlsCfg != nil {
tlsCfg.NextProtos = []string{"h2", "http/1.1"}
}
var errs []error
for _, addr := range cfg.DNS.DoH.Listen {
ln, err := net.Listen("tcp", addr)
if err != nil {
errs = append(errs, fmt.Errorf("bind doh %s: %w", addr, err))
continue
}
actual := ln.Addr().String()
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 90 * time.Second,
// No WriteTimeout: a long-lived h2 stream should not be cut off,
// and ReadHeaderTimeout plus IdleTimeout already bound the damage.
}
closeFn := ln.Close
if tlsCfg != nil {
srv.TLSConfig = tlsCfg
ln = tls.NewListener(ln, tlsCfg)
closeFn = ln.Close
}
s.add(listener{close: closeFn, info: ListenerInfo{
Transport: "doh", Addr: actual, Enabled: true, Running: true,
}})
if tlsErr != nil {
s.log.Warn("doh: running without TLS", "error", tlsErr)
}
s.log.Info("dns listener up", "transport", "doh", "addr", actual, "path", base)
s.wait.Add(1)
go func() {
defer s.wait.Done()
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Debug("doh server stopped", "error", err)
}
}()
s.wait.Add(1)
go func() {
defer s.wait.Done()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
}
return errors.Join(errs...)
}
const contentTypeDNS = "application/dns-message"
// maxGETResponse bounds a DoH GET response so no intermediary truncates it.
const maxGETResponse = 65535
// doHHandler builds the RFC 8484 handler.
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var (
payload []byte
err error
)
switch r.Method {
case http.MethodGet:
// RFC 8484 §4.1 defines exactly one parameter, "dns", holding
// unpadded base64url. An unknown parameter is a 400 rather than a
// 404 so a client can tell its own mistake from a wrong path.
q := r.URL.Query()
if !q.Has("dns") {
writeDoHError(w, http.StatusBadRequest, "missing dns parameter")
return
}
if len(q) > 1 {
for k := range q {
if k != "dns" {
writeDoHError(w, http.StatusBadRequest, "unsupported parameter "+k)
return
}
}
}
payload, err = decodeBase64URL(q.Get("dns"))
if err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
return
}
case http.MethodPost:
if !allowPost {
w.Header().Set("Allow", "GET")
http.Error(w, "POST not allowed", http.StatusMethodNotAllowed)
return
}
if ct := r.Header.Get("Content-Type"); ct != contentTypeDNS {
writeDoHError(w, http.StatusUnsupportedMediaType, "content-type must be "+contentTypeDNS)
return
}
payload, err = readCapped(r, dns.MaxMsgSize)
if err != nil {
writeDoHError(w, http.StatusBadRequest, "cannot read body")
return
}
}
m := new(dns.Msg)
if err := m.Unpack(payload); err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed DNS message")
return
}
if len(m.Question) == 0 {
writeDoHError(w, http.StatusBadRequest, "no question")
return
}
req := s.request(r.Context(), m, httpRemoteAddr(r), "doh")
req.TCP = true
req.DoHPath = r.URL.Path
res := s.eng.Handle(r.Context(), req)
s.bump("doh", "queries")
s.classify("doh", res)
if res.Drop || res.Msg == nil {
// A dropped query still needs a body: unlike UDP, this is a
// well-formed HTTP exchange, so hanging up would read as a
// transport error rather than a policy decision.
res.Msg = new(dns.Msg)
res.Msg.SetRcode(m, dns.RcodeSuccess)
}
wire, err := res.Msg.Pack()
if err != nil {
http.Error(w, "pack failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", contentTypeDNS)
w.Header().Set("Cache-Control", "max-age=0")
if !cfgHideIdentity(s) {
w.Header().Set("Server", "fancy-dns/"+s.version)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(wire)
})
}
func cfgHideIdentity(s *Server) bool {
if s.cfg == nil {
return false
}
return s.cfg.Get().DNS.HideIdentity
}
func writeDoHError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.Error(w, msg, code)
}
// tlsConfig loads a certificate for the encrypted listeners. It returns
// (nil, nil) when no certificate is configured, which means "run plaintext".
func tlsConfig(certFile, keyFile, clientCA, minVersion string) (*tls.Config, error) {
if certFile == "" || keyFile == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load certificate: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: parseTLSVersion(minVersion),
}
if clientCA != "" {
pem, err := os.ReadFile(clientCA)
if err != nil {
return nil, fmt.Errorf("read client CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("client CA: no certificates found")
}
cfg.ClientCAs = pool
cfg.ClientAuth = tls.RequireAndVerifyClientCert
}
return cfg, nil
}
func parseTLSVersion(s string) uint16 {
switch s {
case "1.3":
return tls.VersionTLS13
case "1.0":
return tls.VersionTLS10
default:
return tls.VersionTLS12
}
}
func normaliseBase(p string) string {
if p == "" {
return "/dns-query"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
for len(p) > 1 && p[len(p)-1] == '/' {
p = p[:len(p)-1]
}
return p
}
func readCapped(r *http.Request, max int64) ([]byte, error) {
if r.ContentLength > max {
return nil, fmt.Errorf("body too large: %d", r.ContentLength)
}
// LimitReader plus one extra byte is how you detect "too large" without
// trusting Content-Length, which a client may lie about.
body := io.LimitReader(r.Body, max+1)
buf, err := io.ReadAll(body)
if err != nil {
return nil, err
}
if int64(len(buf)) > max {
return nil, errors.New("body too large")
}
return buf, nil
}
func decodeBase64URL(s string) ([]byte, error) {
// RFC 8484 requires unpadded base64url, but every major client sends
// padding, so accept both rather than fail a legitimate request.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.URLEncoding.DecodeString(s)
}
// httpRemoteAddr turns an http.Request's RemoteAddr into a net.Addr so the
// shared request builder can be used. Over plain HTTP this is the socket peer;
// behind a reverse proxy it is the proxy, which is why the caller must trust
// X-Forwarded-For before that matters (handled in the API layer).
func httpRemoteAddr(r *http.Request) net.Addr {
if r.RemoteAddr == "" {
return &net.TCPAddr{}
}
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return &net.TCPAddr{}
}
return net.UDPAddrFromAddrPort(ap)
}
// addrToAddr extracts a netip.Addr from a net.Addr.
func addrToAddr(a net.Addr) netip.Addr {
switch v := a.(type) {
case *net.UDPAddr:
return v.AddrPort().Addr().Unmap()
case *net.TCPAddr:
return v.AddrPort().Addr().Unmap()
default:
ap, err := netip.ParseAddrPort(a.String())
if err == nil {
return ap.Addr().Unmap()
}
if ip, err := netip.ParseAddr(a.String()); err == nil {
return ip.Unmap()
}
return netip.Addr{}
}
}
// Stats aggregates the per-transport counters plus the engine's own view.
func (s *Server) Stats() HandlerStats {
var out HandlerStats
s.counters.Range(func(k, v any) bool {
key, _ := k.(string)
n := v.(*atomic.Uint64).Load()
dot := strings.IndexByte(key, '.')
if dot < 0 {
return true
}
switch key[dot+1:] {
case "queries":
out.Queries += n
case "blocked":
out.Blocked += n
case "cached":
out.Cached += n
case "errors":
out.Errors += n
case "dropped":
out.Dropped += n
}
return true
})
if s.eng != nil {
out.Inflight = s.eng.Inflight()
}
return out
}
func (s *Server) bump(transport, field string) {
if transport == "" {
transport = "udp"
}
key := transport + "." + field
v, _ := s.counters.LoadOrStore(key, new(atomic.Uint64))
v.(*atomic.Uint64).Add(1)
}
// flusher is implemented by the engine: it pushes buffered cache and log state
// to disk on demand (used by the admin API's "flush" action).
type flusher interface{ Flush() }
// Flush pushes buffered state to disk.
func (s *Server) Flush() {
if f, ok := s.eng.(flusher); ok {
f.Flush()
}
}
+56
View File
@@ -0,0 +1,56 @@
package server
import "sync/atomic"
// HandlerStats aggregates the per-transport counters plus the engine's own
// view. It is the "what is this thing doing right now" summary the admin API
// and the health endpoint read.
type HandlerStats struct {
// Queries is every query the server accepted across all transports.
Queries uint64 `json:"queries"`
// Blocked is queries answered by a blocklist rule.
Blocked uint64 `json:"blocked"`
// Cached is queries answered from the cache.
Cached uint64 `json:"cached"`
// Errors is queries answered with SERVFAIL, dropped or malformed.
Errors uint64 `json:"errors"`
// Dropped is queries answered with silence.
Dropped uint64 `json:"dropped"`
// Inflight is the current in-flight count.
Inflight int64 `json:"inflight"`
}
// ByTransport returns a per-transport breakdown for the UI's transport tab.
func (s *Server) ByTransport() map[string]TransportStats {
out := map[string]TransportStats{}
for _, t := range []string{"udp", "tcp", "dot", "doh"} {
ts := TransportStats{Transport: t}
ts.Queries = s.load(t, "queries")
ts.Blocked = s.load(t, "blocked")
ts.Cached = s.load(t, "cached")
ts.Errors = s.load(t, "errors")
ts.Truncated = s.load(t, "truncated")
ts.Sent = s.load(t, "sent")
out[t] = ts
}
return out
}
func (s *Server) load(transport, field string) uint64 {
v, ok := s.counters.Load(transport + "." + field)
if !ok {
return 0
}
return v.(*atomic.Uint64).Load()
}
// TransportStats is a per-transport counter set.
type TransportStats struct {
Transport string `json:"transport"`
Queries uint64 `json:"queries"`
Blocked uint64 `json:"blocked"`
Cached uint64 `json:"cached"`
Errors uint64 `json:"errors"`
Truncated uint64 `json:"truncated"`
Sent uint64 `json:"sent"`
}
+1
View File
@@ -0,0 +1 @@
Build artifacts land here: run `make web` (npm ci && npm run build) then rebuild the binary.
+3
View File
@@ -0,0 +1,3 @@
<!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>
+99
View File
@@ -0,0 +1,99 @@
// Package web serves the embedded admin console as a single-page app.
//
// The assets are compiled into the binary, so a deployment is one file plus a
// config; there is no node process in production and no CDN dependency. When
// the assets are absent (a `go build` without `npm run build`) the server says
// so plainly instead of returning a confusing 404.
package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
)
//go:embed all:dist
var dist embed.FS
// Available reports whether compiled assets are present in this binary.
func Available() bool {
_, err := dist.Open("dist/index.html")
return err == nil
}
// Handler serves the console, falling back to index.html for client-side
// routes so a deep link like /logs works on a hard refresh.
func Handler(basePath string) (http.Handler, error) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, fmt.Errorf("web: open assets: %w", err)
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(missingAssets))
}), nil
}
files := http.FileServer(http.FS(sub))
prefix := "/"
if basePath != "" && basePath != "/" {
prefix = "/" + strings.Trim(basePath, "/") + "/"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Hashed asset filenames are immutable, so they can be cached hard.
if strings.HasPrefix(r.URL.Path, prefix+"assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
clean := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(prefix, "/"))
clean = strings.TrimPrefix(clean, "/")
if clean == "" {
clean = "index.html"
}
if _, err := fs.Stat(sub, clean); err != nil {
// Unknown path: hand it to the SPA router.
b, rerr := fs.ReadFile(sub, "index.html")
if rerr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return
}
files.ServeHTTP(w, r)
}), nil
}
const missingAssets = `<!doctype html>
<html><head><meta charset="utf-8"><title>fancy-dns</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#0f172a;background:#f8fafc}
code{background:#e2e8f0;padding:.15em .4em;border-radius:.25rem}</style></head>
<body><h1>fancy-dns</h1>
<p>The admin API is running, but the web console was not built into this binary.</p>
<p>Build it with <code>make web</code> (or <code>cd web &amp;&amp; npm ci &amp;&amp; npm run build</code>), then rebuild the binary.</p>
<p>The JSON API is available under <code>/api</code>.</p></body></html>`
// Version is the asset build stamp, exposed in the UI footer.
func Version() string {
b, err := dist.ReadFile("dist/.build-version")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// CacheControlMaxAge is the immutable-asset max age in seconds.
const CacheControlMaxAge = 31536000
// maxAgeHeader builds the Cache-Control value for a hashed asset.
func maxAgeHeader(seconds int) string {
return "public, max-age=" + strconv.Itoa(seconds) + ", immutable"
}
+532
View File
@@ -0,0 +1,532 @@
// Package zonefile parses RFC 1035 master files ("zone files") with the
// $TTL/$ORIGIN directives, parenthesised continuations and RFC 1982/1996
// serial arithmetic.
//
// fancy-dns uses master files for two things: locally defined authoritative
// data, and RPZ-style response-policy zones that double as a blocklist source.
// Sharing one parser keeps operator expectations ("it is just a zone file")
// true for both.
package zonefile
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Record is one parsed resource record with its origin resolved.
type Record struct {
// Name is the fully-qualified, lowercased owner name.
Name string
TTL uint32
RR dns.RR
// Line is the 1-based source line, for error reporting.
Line int
// File is the source path, for error reporting.
File string
}
// Text renders the record back into zone-file syntax.
func (r Record) Text() string { return r.RR.String() }
// Zone is the result of parsing a master file.
type Zone struct {
// Origin is the zone's apex as configured.
Origin string
// Records are in file order.
Records []Record
// File is the path parsed, or "" for reader input.
File string
// MinTTL is the smallest explicit TTL seen.
MinTTL uint32
// SOA holds the zone's start-of-authority record, if present.
SOA *dns.SOA
// DefaultTTL is the $TTL directive value, else the SOA minimum.
DefaultTTL uint32
}
// Options tunes parsing.
type Options struct {
// Origin overrides $ORIGIN and is the default when a file has none.
Origin string
// File is used in error messages.
File string
// MaxIncludeDepth bounds $INCLUDE recursion; 0 disables $INCLUDE.
MaxIncludeDepth int
// BaseDir is where relative $INCLUDE paths resolve; empty means the
// directory of the primary file.
BaseDir string
// DefaultTTL is the TTL applied to records that carry none, when the
// file has neither $TTL nor an SOA.
DefaultTTL uint32
}
// Parse reads a master file from r.
func Parse(r io.Reader, opt Options) (*Zone, error) {
origin := ensureDot(opt.Origin)
if origin == "" {
origin = "."
}
if opt.BaseDir == "" && opt.File != "" {
opt.BaseDir = dirOf(opt.File)
}
z := &Zone{Origin: origin, File: opt.File, DefaultTTL: opt.DefaultTTL}
var lastOwner string
lineno := 0
lines := bufio.NewScanner(r)
lines.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for lines.Scan() {
startLine := lineno + 1
lineno++
stripped := strings.TrimSpace(stripComment(lines.Text()))
if stripped == "" {
continue
}
// Fold parenthesised continuations into one logical line.
logical, err := fold(lines, stripped, &lineno, opt)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
if strings.HasPrefix(logical, "$") {
if err := z.directive(logical, opt); err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
lastOwner = z.Origin
continue
}
assembled, err := z.assemble(logical, lastOwner)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
}
rr, err := dns.NewRR(assembled)
if err != nil {
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
}
rec := Record{
Name: strings.ToLower(ensureDot(rr.Header().Name)),
TTL: rr.Header().Ttl,
RR: rr,
Line: startLine,
File: opt.File,
}
if rec.TTL == 0 {
rec.TTL = z.DefaultTTL
}
if rec.TTL > 0 && (z.MinTTL == 0 || rec.TTL < z.MinTTL) {
z.MinTTL = rec.TTL
}
if soa, ok := rr.(*dns.SOA); ok {
z.SOA = cloneSOA(soa)
if rec.TTL > 0 {
z.DefaultTTL = rec.TTL
} else if z.DefaultTTL == 0 {
z.DefaultTTL = soa.Minttl
}
}
z.Records = append(z.Records, rec)
lastOwner = rec.Name
}
if err := lines.Err(); err != nil {
return nil, fmt.Errorf("%s: read: %w", opt.File, err)
}
return z, nil
}
// fold joins a line with any parenthesised continuation lines still pending on
// the scanner. open reports how many continuations are still outstanding after
// the returned line, so a caller can loop until it reaches zero.
func fold(lines *bufio.Scanner, first string, lineno *int, opt Options) (string, error) {
depth := strings.Count(first, "(") - strings.Count(first, ")")
var sb strings.Builder
sb.WriteString(deparen(first))
for depth > 0 {
if !lines.Scan() {
return "", fmt.Errorf("unterminated parenthesis")
}
*lineno++
next := strings.TrimSpace(stripComment(lines.Text()))
if next == "" {
continue
}
depth += strings.Count(next, "(") - strings.Count(next, ")")
sb.WriteString(" ")
sb.WriteString(deparen(next))
}
return strings.TrimSpace(sb.String()), nil
}
// deparen removes continuation markers, leaving a single-space token stream.
func deparen(s string) string {
s = strings.ReplaceAll(s, "(", " ")
s = strings.ReplaceAll(s, ")", " ")
return strings.TrimSpace(s)
}
// assemble fills in the omitted owner name, TTL and class so that the result
// is a complete RR line that dns.NewRR will accept.
func (z *Zone) assemble(line, lastOwner string) (string, error) {
fields := splitFields(line)
if len(fields) == 0 {
return "", fmt.Errorf("empty record")
}
var head string
var rest []string
if isRRTYPE(fields[0]) {
// Owner omitted: it repeats the previous record's owner.
head = lastOwner
rest = fields
} else {
head = fields[0]
rest = fields[1:]
}
if head == "" {
return "", fmt.Errorf("record has no owner name and no previous owner to inherit")
}
head = relative(head, z.Origin)
out := []string{head}
// Consume optional TTL and class in any order, then the type.
for len(rest) > 0 && !isRRTYPE(rest[0]) {
switch {
case isTTLToken(rest[0]):
out = append(out, rest[0])
case isClassToken(rest[0]):
out = append(out, strings.ToUpper(rest[0]))
default:
return "", fmt.Errorf("unexpected token %q before the record type", rest[0])
}
rest = rest[1:]
}
if len(rest) == 0 {
return "", fmt.Errorf("record %q has no type", head)
}
out = append(out, rest...)
return strings.Join(out, " "), nil
}
// directive handles $TTL, $ORIGIN and $INCLUDE.
func (z *Zone) directive(line string, opt Options) error {
fields := strings.Fields(line)
switch strings.ToUpper(fields[0]) {
case "$TTL":
if len(fields) < 2 {
return fmt.Errorf("$TTL needs a value")
}
ttl, err := ParseTTL(fields[1])
if err != nil {
return err
}
z.DefaultTTL = ttl
case "$ORIGIN":
if len(fields) < 2 {
return fmt.Errorf("$ORIGIN needs a name")
}
z.Origin = ensureDot(fields[1])
case "$INCLUDE":
if len(fields) < 2 {
return fmt.Errorf("$INCLUDE needs a file name")
}
if opt.MaxIncludeDepth <= 0 {
return fmt.Errorf("$INCLUDE %q is disabled in this context", fields[1])
}
return fmt.Errorf("$INCLUDE %q is not supported; use blocklist.local_zones or a list source instead", fields[1])
}
return nil
}
// rrTypes is the set of record types the assembler recognises as a type
// token; anything absent is treated as an owner name.
var rrTypes = map[string]bool{}
func init() {
for _, t := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeNS, dns.TypeCNAME, dns.TypeSOA, dns.TypePTR,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeDS, dns.TypeDNSKEY,
dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM, dns.TypeRRSIG, dns.TypeNSEC,
dns.TypeNAPTR, dns.TypeTLSA, dns.TypeSSHFP, dns.TypeSVCB, dns.TypeHTTPS,
dns.TypeHINFO, dns.TypeLOC, dns.TypeSPF, dns.TypeAXFR,
dns.TypeIXFR, dns.TypeANY, dns.TypeURI, dns.TypeDHCID, dns.TypeSMIMEA,
} {
rrTypes[dns.TypeToString[t]] = true
}
}
// cloneSOA copies an SOA because dns.SOA.Copy is unexported.
func cloneSOA(in *dns.SOA) *dns.SOA {
if in == nil {
return nil
}
out := *in // value copy is already deep: Ns/Mbox are plain strings
return &out
}
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
func isTTLToken(s string) bool {
if s == "" || s[0] < '0' || s[0] > '9' {
return false
}
if _, err := ParseTTL(s); err != nil {
return false
}
return !isClassToken(s)
}
func isClassToken(s string) bool {
switch strings.ToUpper(s) {
case "IN", "CH", "HS":
return true
}
return false
}
func where(file string) string {
if file == "" {
return "zone"
}
return file
}
func dirOf(p string) string {
i := strings.LastIndexByte(p, '/')
if i < 0 {
return "."
}
return p[:i]
}
func ensureDot(s string) string {
if s == "" {
return ""
}
if strings.HasSuffix(s, ".") {
return s
}
return s + "."
}
// relative expands a possibly-relative name against origin.
func relative(name, origin string) string {
if name == "@" {
return ensureDot(origin)
}
// A name that already ends in a dot is absolute, so it never gets the
// origin appended. This is the common case in RPZ-style policy files,
// where owners are written fully qualified.
if strings.HasSuffix(name, ".") {
return name
}
if origin == "" || origin == "." {
return ensureDot(name)
}
return ensureDot(name) + origin
}
func stripComment(s string) string {
inQuote := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
inQuote = !inQuote
case ';':
if !inQuote {
return s[:i]
}
}
}
return s
}
// splitFields splits on whitespace while keeping quoted strings intact (TXT
// payloads are handed to dns.NewRR untouched).
func splitFields(s string) []string {
var out []string
var cur strings.Builder
inQuote := false
started := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\\' && i+1 < len(s):
cur.WriteByte(c)
i++
cur.WriteByte(s[i])
started = true
case c == '"':
inQuote = !inQuote
cur.WriteByte(c)
started = true
case (c == ' ' || c == '\t') && !inQuote:
if started {
out = append(out, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteByte(c)
started = true
}
}
if started {
out = append(out, cur.String())
}
return out
}
// ParseTTL parses the RFC 1035 TTL grammar (unit suffixes s/m/h/d/w) and a
// bare number of seconds.
func ParseTTL(s string) (uint32, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty TTL")
}
var total uint64
i := 0
for i < len(s) {
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if start == i {
return 0, fmt.Errorf("bad TTL %q", s)
}
n, err := strconv.ParseUint(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("bad TTL %q", s)
}
unitStart := i
for i < len(s) && (s[i] < '0' || s[i] > '9') {
i++
}
var mult uint64
switch strings.ToLower(s[unitStart:i]) {
case "", "s", "sec", "secs", "second", "seconds":
mult = 1
case "m", "min", "mins", "minute", "minutes":
mult = 60
case "h", "hour", "hours":
mult = 3600
case "d", "day", "days":
mult = 86400
case "w", "week", "weeks":
mult = 604800
default:
return 0, fmt.Errorf("bad TTL unit in %q", s)
}
total += n * mult
if total > 0xFFFFFFFF {
return 0, fmt.Errorf("TTL %q overflows", s)
}
}
return uint32(total), nil
}
// FormatTTL renders a TTL in the compact unit form operators expect.
func FormatTTL(ttl uint32) string {
switch {
case ttl >= 604800 && ttl%604800 == 0:
return strconv.FormatUint(uint64(ttl/604800), 10) + "w"
case ttl >= 86400 && ttl%86400 == 0:
return strconv.FormatUint(uint64(ttl/86400), 10) + "d"
case ttl >= 3600 && ttl%3600 == 0:
return strconv.FormatUint(uint64(ttl/3600), 10) + "h"
case ttl >= 60 && ttl%60 == 0:
return strconv.FormatUint(uint64(ttl/60), 10) + "m"
default:
return strconv.FormatUint(uint64(ttl), 10)
}
}
// SerialNext applies RFC 1982 serial arithmetic: "+N"/"-N" or a plain number.
func SerialNext(current uint32, spec string) (uint32, error) {
spec = strings.TrimSpace(spec)
switch {
case spec == "":
return 0, fmt.Errorf("empty serial")
case spec[0] == '+':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, uint32(n)), nil
case spec[0] == '-':
n, err := strconv.ParseUint(spec[1:], 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return addSerial(current, -uint32(n)), nil
default:
n, err := strconv.ParseUint(spec, 10, 32)
if err != nil {
return 0, fmt.Errorf("bad serial %q", spec)
}
return uint32(n), nil
}
}
func addSerial(a, b uint32) uint32 {
sum := (uint64(a) + uint64(b)) & 0xFFFFFFFF
// Both operands in the "high" half means the sum stays inside the epoch,
// so no wrap correction applies (RFC 1982 §3.2).
if a >= 0x80000000 && b >= 0x80000000 {
return uint32(sum)
}
if sum >= 0x100000000 {
return uint32(sum - 0x100000000)
}
return uint32(sum)
}
// ParseFile reads and parses a master file from disk.
func ParseFile(path string, origin string) (*Zone, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return Parse(f, Options{Origin: origin, File: path, BaseDir: dirOf(path)})
}
// ParseFiles parses every file in paths and merges the records into one zone.
// Origins default to the file's base name, which is what a directory of
// per-list policy zones usually wants.
func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
z := &Zone{Origin: "."}
for _, p := range paths {
origin := originFor(p)
sub, err := ParseFile(p, origin)
if err != nil {
return nil, err
}
z.Records = append(z.Records, sub.Records...)
if sub.DefaultTTL > 0 {
z.DefaultTTL = sub.DefaultTTL
}
if sub.SOA != nil {
z.SOA = sub.SOA
}
}
return z, nil
}
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
// "ads.zone" both become "ads.zone.".
func OriginFromPath(p string) string {
base := p
if i := strings.LastIndexByte(base, '/'); i >= 0 {
base = base[i+1:]
}
if i := strings.IndexByte(base, '.'); i > 0 {
base = base[:i]
}
return ensureDot(base)
}
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+13
View File
@@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2488
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.48.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+122
View File
@@ -0,0 +1,122 @@
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'
function App() {
const [count, setCount] = useState(0)
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>
</>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
Database,
Globe,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
Network,
PanelLeftClose,
ScrollText,
Settings,
Users,
X,
} from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { logout } from '../../lib/api.ts'
import type { Health } from '../../lib/types.ts'
import { HealthPill } from './HealthPill.tsx'
import { ThemeToggle } from './ThemeToggle.tsx'
import { Tooltip } from '../ui/Tooltip.tsx'
interface NavItem {
to: string
label: string
icon: LucideIcon
end?: boolean
}
const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText },
{ to: '/cache', label: 'Cache', icon: Database },
{ to: '/settings', label: 'Settings', icon: Settings },
]
const MOBILE_QUERY = '(max-width: 1023px)'
export function AppShell({
children,
health,
nodeId,
}: {
children: ReactNode
health?: Health
nodeId?: string
}) {
const [collapsed, setCollapsed] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const location = useLocation()
const navigate = useNavigate()
useEffect(() => {
const query = window.matchMedia(MOBILE_QUERY)
const sync = (): void => {
setIsMobile(query.matches)
if (!query.matches) setSheetOpen(false)
}
sync()
query.addEventListener('change', sync)
return () => query.removeEventListener('change', sync)
}, [])
// Any navigation closes the mobile sheet.
useEffect(() => {
setSheetOpen(false)
}, [location.pathname])
useEffect(() => {
if (!sheetOpen) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setSheetOpen(false)
}
document.addEventListener('keydown', onKey)
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
}
}, [sheetOpen])
const onSignOut = useCallback(() => {
setSigningOut(true)
logout()
.catch(() => {
/* the token is cleared either way */
})
.finally(() => {
setSigningOut(false)
navigate('/auth/login', { replace: true })
})
}, [navigate])
const rail = !isMobile && collapsed
return (
<div className="min-h-svh bg-canvas">
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
the hamburger that opens the sheet instead. */}
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
{isMobile ? (
<button
type="button"
aria-label="Open navigation"
onClick={() => setSheetOpen(true)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<Menu size={18} strokeWidth={2} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => setCollapsed((value) => !value)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<PanelLeftClose
size={18}
strokeWidth={2}
aria-hidden="true"
className={cn('transition-transform', collapsed && 'rotate-180')}
/>
</button>
)}
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
</div>
<div className="ml-auto flex items-center gap-2">
{nodeId !== undefined && nodeId !== '' && (
<span
title={`Node ${nodeId}`}
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
>
{nodeId}
</span>
)}
{health !== undefined && <HealthPill health={health} />}
<ThemeToggle />
<button
type="button"
aria-label="Sign out"
title="Sign out"
disabled={signingOut}
onClick={onSignOut}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
>
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</header>
{/* Desktop sidebar. */}
<aside
className={cn(
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
rail ? 'w-16' : 'w-56',
)}
>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
rail && 'justify-center px-0',
)
}
title={rail ? item.label : undefined}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
{!rail && <span className="truncate">{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
{/* Mobile sheet. */}
{sheetOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="absolute inset-0 bg-black/60"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Navigation"
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
>
<div className="flex h-14 items-center justify-between border-b border-line px-3">
<span className="text-sm font-semibold text-fg">fancy-dns</span>
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={() => setSheetOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
)
}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
<span className="truncate">{item.label}</span>
</NavLink>
))}
</nav>
</aside>
</div>
)}
<main
className={cn(
'pt-14 transition-[padding] duration-200',
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
)}
>
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
{children}
</div>
</main>
</div>
)
}
/** Re-exported so pages can label collapsed rail items without importing ui. */
export { Tooltip }
+32
View File
@@ -0,0 +1,32 @@
import type { Health } from '../../lib/types.ts'
import { cn } from '../../lib/cn.ts'
const STYLES: Record<Health, { dot: string; text: string; ring: string }> = {
ok: { dot: 'bg-ok', text: 'text-ok', ring: 'border-ok/30 bg-ok-soft' },
degraded: { dot: 'bg-warn', text: 'text-warn', ring: 'border-warn/30 bg-warn-soft' },
down: { dot: 'bg-danger', text: 'text-danger', ring: 'border-danger/30 bg-danger-soft' },
}
const DEFAULT_LABEL: Record<Health, string> = {
ok: 'Healthy',
degraded: 'Degraded',
down: 'Down',
}
export function HealthPill({ health, label, className }: { health: Health; label?: string; className?: string }) {
const style = STYLES[health] ?? STYLES.degraded
return (
<span
title={`Node health: ${health}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium whitespace-nowrap',
style.ring,
style.text,
className,
)}
>
<span className={cn('size-1.5 rounded-full', style.dot, health === 'down' && 'animate-pulse-soft')} />
{label ?? DEFAULT_LABEL[health] ?? health}
</span>
)
}
@@ -0,0 +1,18 @@
import { Skeleton } from '../ui/Skeleton.tsx'
import { cn } from '../../lib/cn.ts'
export function LoadingBlock({ rows = 5, className }: { rows?: number; className?: string }) {
const count = Math.max(1, Math.min(rows, 50))
return (
<div className={cn('flex w-full flex-col gap-2', className)} aria-busy="true" aria-live="polite">
<span className="sr-only">Loading…</span>
{Array.from({ length: count }, (_, index) => (
<div key={index} className="flex items-center gap-3">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '../ui/Button.tsx'
import { cn } from '../../lib/cn.ts'
export function PageError({
error,
onRetry,
className,
}: {
error: string
onRetry?: () => void
className?: string
}) {
return (
<div
role="alert"
className={cn(
'flex flex-col items-start gap-3 rounded-xl border border-danger/30 bg-danger-soft p-4 sm:flex-row sm:items-center sm:justify-between',
className,
)}
>
<div className="flex min-w-0 items-start gap-2.5">
<AlertTriangle size={18} strokeWidth={2} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">Could not load this view</p>
<p className="mt-0.5 text-sm break-words text-fg-muted">{error}</p>
</div>
</div>
{onRetry && (
<Button variant="outline" icon={RefreshCw} onClick={onRetry} className="shrink-0">
Retry
</Button>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function PageHeader({
title,
description,
actions,
className,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}) {
return (
<header className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-fg">{title}</h1>
{description !== undefined && (
<p className="mt-0.5 text-sm text-fg-muted">{description}</p>
)}
</div>
{actions !== undefined && (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
)}
</header>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export type Theme = 'dark' | 'light'
const STORAGE_KEY = 'fancydns.theme'
function readStoredTheme(): Theme {
try {
const value = localStorage.getItem(STORAGE_KEY)
if (value === 'light' || value === 'dark') return value
} catch {
/* storage disabled: fall through to the default */
}
return 'dark'
}
/**
* Runs at module evaluation, i.e. while the bundle is still being parsed and
* before the first paint, so the console never flashes the wrong theme.
*/
const INITIAL_THEME: Theme = (() => {
if (typeof document === 'undefined') return 'dark'
const theme = readStoredTheme()
document.documentElement.dataset.theme = theme
return theme
})()
export function ThemeToggle({ className }: { className?: string }) {
const [theme, setTheme] = useState<Theme>(INITIAL_THEME)
// A second tab changing the theme should not desync this one.
useEffect(() => {
const onStorage = (event: StorageEvent): void => {
if (event.key !== STORAGE_KEY) return
const next = readStoredTheme()
document.documentElement.dataset.theme = next
setTheme(next)
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const apply = (next: Theme): void => {
document.documentElement.dataset.theme = next
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* not fatal: the choice just does not survive a reload */
}
setTheme(next)
}
const next: Theme = theme === 'dark' ? 'light' : 'dark'
return (
<button
type="button"
onClick={() => apply(next)}
title={`Switch to ${next} theme`}
aria-label={`Switch to ${next} theme`}
className={cn(
'inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg',
className,
)}
>
{theme === 'dark' ? (
<Moon size={16} strokeWidth={2} aria-hidden="true" />
) : (
<Sun size={16} strokeWidth={2} aria-hidden="true" />
)}
</button>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** The shell and the page-level helpers. */
export { AppShell } from './AppShell.tsx'
export { PageHeader } from './PageHeader.tsx'
export { ThemeToggle } from './ThemeToggle.tsx'
export { HealthPill } from './HealthPill.tsx'
export { PageError } from './PageError.tsx'
export { LoadingBlock } from './LoadingBlock.tsx'
export type { Theme } from './ThemeToggle.tsx'
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export type BadgeTone = 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted'
export const BADGE_TONES: Record<BadgeTone, string> = {
default: 'border-line bg-surface-3 text-fg',
ok: 'border-transparent bg-ok-soft text-ok',
warn: 'border-transparent bg-warn-soft text-warn',
danger: 'border-transparent bg-danger-soft text-danger',
info: 'border-transparent bg-info-soft text-info',
muted: 'border-transparent bg-surface-3 text-fg-subtle',
}
export function Badge({
tone = 'default',
children,
className,
}: {
tone?: BadgeTone
children: ReactNode
className?: string
}) {
return (
<span
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs font-medium whitespace-nowrap',
BADGE_TONES[tone],
className,
)}
>
{children}
</span>
)
}
+72
View File
@@ -0,0 +1,72 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Spinner } from './Spinner.tsx'
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
export const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-accent-strong text-accent-fg hover:brightness-110 active:brightness-95',
secondary: 'bg-surface-3 text-fg hover:bg-line-strong border border-line',
ghost: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
danger: 'bg-danger text-white hover:brightness-110 active:brightness-95',
outline: 'border border-line-strong text-fg hover:bg-surface-3',
}
export const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'h-8 gap-1.5 px-2.5 text-xs',
md: 'h-9 gap-2 px-3 text-sm',
lg: 'h-11 gap-2 px-5 text-sm',
icon: 'size-9 justify-center',
}
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
icon?: LucideIcon
iconRight?: LucideIcon
children?: ReactNode
}
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
icon: Icon,
iconRight: IconRight,
className,
children,
disabled,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
// A pending action must not be fireable twice: a double "Flush cache"
// is a support ticket.
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
'inline-flex shrink-0 items-center rounded-lg font-medium whitespace-nowrap transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
BUTTON_VARIANTS[variant],
BUTTON_SIZES[size],
className,
)}
{...props}
>
{loading ? (
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
) : (
Icon && <Icon size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
)}
{children}
{IconRight && !loading && (
<IconRight size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
)}
</button>
)
}
+111
View File
@@ -0,0 +1,111 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Card({
title,
subtitle,
actions,
children,
className,
padded = true,
}: {
title?: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children?: ReactNode
className?: string
padded?: boolean
}) {
const hasHeader = title !== undefined || subtitle !== undefined || actions !== undefined
return (
<section
className={cn(
'flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-panel',
className,
)}
>
{hasHeader && (
<CardHeader actions={actions}>
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
{title === undefined && subtitle !== undefined && (
<p className="text-sm text-fg-muted">{subtitle}</p>
)}
</CardHeader>
)}
<CardBody padded={padded}>{children}</CardBody>
</section>
)
}
export function CardHeader({
title,
subtitle,
actions,
children,
className,
}: {
title?: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children?: ReactNode
className?: string
}) {
return (
<header
className={cn(
'flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface-2 px-4 py-3',
className,
)}
>
<div className="min-w-0">
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
{children}
</div>
{actions !== undefined && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)
}
export function CardTitle({
children,
subtitle,
className,
}: {
children?: ReactNode
subtitle?: ReactNode
className?: string
}) {
return (
<div className={cn('min-w-0', className)}>
<h2 className="truncate text-sm font-semibold text-fg">{children}</h2>
{subtitle !== undefined && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}
</div>
)
}
export function CardBody({
children,
className,
padded = true,
}: {
children?: ReactNode
className?: string
padded?: boolean
}) {
return (
<div className={cn('flex-1 min-w-0', padded && 'p-4', className)}>{children}</div>
)
}
export function CardFooter({ children, className }: { children?: ReactNode; className?: string }) {
return (
<footer
className={cn(
'flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3',
className,
)}
>
{children}
</footer>
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import { Dialog } from './Dialog.tsx'
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Confirm',
destructive = false,
pending = false,
}: {
open: boolean
onClose: () => void
onConfirm: () => void | Promise<unknown>
title: ReactNode
message: ReactNode
confirmLabel?: string
destructive?: boolean
pending?: boolean
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
onClick={() => void onConfirm()}
loading={pending}
>
{confirmLabel}
</Button>
</>
}
>
<p className="text-fg-muted">{message}</p>
</Dialog>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { IconButton } from './IconButton.tsx'
/** Copies a value (a rule, an address, a key) to the clipboard. */
export function CopyButton({
value,
label = 'Copy',
className,
}: {
value: string
label?: string
className?: string
}) {
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
return () => {
if (timer.current !== undefined) clearTimeout(timer.current)
}
}, [])
const copy = (): void => {
const clipboard = navigator?.clipboard
if (!clipboard) return
void clipboard.writeText(value).then(
() => {
setCopied(true)
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1500)
},
() => {
// A denied clipboard permission is not worth a toast; the button just
// does not flip.
},
)
}
return (
<IconButton
icon={copied ? Check : Copy}
label={copied ? 'Copied' : label}
variant="ghost"
size="sm"
onClick={copy}
className={cn(copied && 'text-ok', className)}
/>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useId, useRef } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { IconButton } from './IconButton.tsx'
const SIZES = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const
/**
* Native `<dialog>`: focus trapping, inert background and Escape handling come
* from the platform instead of a focus-trap library.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
}: {
open: boolean
onClose: () => void
title: ReactNode
description?: ReactNode
children?: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}) {
const ref = useRef<HTMLDialogElement>(null)
const titleId = useId()
const descId = useId()
useEffect(() => {
const el = ref.current
if (!el) return
if (open && !el.open) {
if (typeof el.showModal === 'function') el.showModal()
else el.setAttribute('open', '')
} else if (!open && el.open) {
el.close()
}
}, [open])
// The element can be removed while open (route change): close it so the
// top layer does not keep a detached node.
useEffect(() => {
const el = ref.current
return () => {
if (el?.open) el.close()
}
}, [])
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>): void => {
// Only a click on the dialog element itself is a backdrop click; clicks
// inside the panel bubble up with a different target.
if (event.target === ref.current) onClose()
}
return (
<dialog
ref={ref}
aria-labelledby={titleId}
aria-describedby={description !== undefined ? descId : undefined}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClose={onClose}
onClick={onBackdropClick}
className={cn(
'm-auto w-[calc(100vw-2rem)] rounded-xl border border-line bg-surface p-0 text-fg shadow-panel backdrop:bg-black/60',
'open:animate-slide-up',
SIZES[size],
)}
>
<div className="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
<div className="min-w-0">
<h2 id={titleId} className="text-sm font-semibold">
{title}
</h2>
{description !== undefined && (
<p id={descId} className="mt-0.5 text-xs text-fg-muted">
{description}
</p>
)}
</div>
<IconButton icon={X} label="Close" onClick={onClose} />
</div>
<div className="max-h-[70vh] overflow-auto px-4 py-4 text-sm">{children}</div>
{footer !== undefined && (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3">
{footer}
</div>
)}
</dialog>
)
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Inbox } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
className,
}: {
icon?: LucideIcon
title: ReactNode
description?: ReactNode
action?: ReactNode
className?: string
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-line px-6 py-12 text-center',
className,
)}
>
<span className="grid size-11 place-items-center rounded-full bg-surface-3 text-fg-subtle">
<Icon size={20} strokeWidth={1.75} aria-hidden="true" />
</span>
<div className="max-w-md">
<p className="text-sm font-medium text-fg">{title}</p>
{description !== undefined && (
<p className="mt-1 text-sm text-fg-muted">{description}</p>
)}
</div>
{action !== undefined && <div className="mt-1 flex items-center gap-2">{action}</div>}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { BUTTON_SIZES, BUTTON_VARIANTS } from './Button.tsx'
import type { ButtonSize, ButtonVariant } from './Button.tsx'
import { Tooltip } from './Tooltip.tsx'
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
icon: LucideIcon
label: string
variant?: ButtonVariant
size?: ButtonSize
tooltipSide?: 'top' | 'bottom' | 'left' | 'right'
}
export function IconButton({
icon: Icon,
label,
variant = 'ghost',
size = 'icon',
tooltipSide = 'top',
className,
type = 'button',
...props
}: IconButtonProps) {
return (
<Tooltip content={label} side={tooltipSide}>
<button
type={type}
aria-label={label}
title={label}
className={cn(
'inline-flex shrink-0 items-center rounded-lg transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
BUTTON_VARIANTS[variant],
BUTTON_SIZES[size],
className,
)}
{...props}
>
<Icon size={16} strokeWidth={2} aria-hidden="true" />
</button>
</Tooltip>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useId } from 'react'
import type { InputHTMLAttributes, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export const FIELD_BASE =
'w-full rounded-lg border bg-surface-2 text-fg placeholder:text-fg-subtle transition-colors focus:outline-none focus-visible:border-accent disabled:opacity-60'
export function FieldFrame({
id,
label,
hint,
error,
children,
}: {
id: string
label?: ReactNode
hint?: ReactNode
error?: ReactNode
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
{label !== undefined && (
<label htmlFor={id} className="text-xs font-medium text-fg-muted">
{label}
</label>
)}
{children}
{error !== undefined && error !== null && error !== '' ? (
<p id={`${id}-error`} role="alert" className="text-xs text-danger">
{error}
</p>
) : hint !== undefined ? (
<p id={`${id}-hint`} className="text-xs text-fg-subtle">
{hint}
</p>
) : null}
</div>
)
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
icon?: LucideIcon
className?: string
}
export function Input({ label, hint, error, icon: Icon, className, id, ...props }: InputProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint !== undefined ? `${inputId}-hint` : undefined
const input = (
<div className="relative">
{Icon && (
<Icon
size={15}
strokeWidth={2}
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-fg-subtle"
/>
)}
<input
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'h-9 px-2.5 text-sm',
Icon && 'pl-8',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
/>
</div>
)
if (label === undefined && hint === undefined && error === undefined) return input
return (
<FieldFrame id={inputId} label={label} hint={hint} error={error}>
{input}
</FieldFrame>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Kbd({ children, className }: { children: ReactNode; className?: string }) {
return (
<kbd
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-line-strong bg-surface-3 px-1.5 font-mono text-[10px] font-medium text-fg-muted',
className,
)}
>
{children}
</kbd>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Button } from './Button.tsx'
export function Pagination({
page,
perPage,
total,
onPage,
className,
}: {
page: number
perPage: number
total: number
onPage: (page: number) => void
className?: string
}) {
const size = perPage > 0 ? perPage : 1
const pages = Math.max(1, Math.ceil(total / size))
const current = Math.min(Math.max(1, page), pages)
const first = (current - 1) * size + 1
const last = Math.min(total, current * size)
return (
<div
className={cn(
'flex flex-wrap items-center justify-between gap-3 text-xs text-fg-muted',
className,
)}
>
<p className="tabular">
{total === 0 ? 'no rows' : `${first}–${last} of ${total.toLocaleString('en-US')}`}
</p>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
icon={ChevronLeft}
disabled={current <= 1}
onClick={() => onPage(current - 1)}
aria-label="Previous page"
>
Prev
</Button>
<span className="tabular px-1">
{current} / {pages}
</span>
<Button
variant="outline"
size="sm"
iconRight={ChevronRight}
disabled={current >= pages}
onClick={() => onPage(current + 1)}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { cn } from '../../lib/cn.ts'
export type ProgressTone = 'default' | 'ok' | 'warn' | 'danger' | 'info'
const TONES: Record<ProgressTone, string> = {
default: 'bg-accent',
ok: 'bg-ok',
warn: 'bg-warn',
danger: 'bg-danger',
info: 'bg-info',
}
export function Progress({
value,
max = 100,
tone = 'default',
className,
showLabel = false,
}: {
value: number
max?: number
tone?: ProgressTone
className?: string
showLabel?: boolean
}) {
const safeMax = Number.isFinite(max) && max > 0 ? max : 100
const safeValue = Number.isFinite(value) ? value : 0
const pct = Math.max(0, Math.min(100, (safeValue / safeMax) * 100))
return (
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
<div
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full min-w-0 overflow-hidden rounded-full bg-surface-3"
>
<div
className={cn('h-full rounded-full transition-[width] duration-300', TONES[tone])}
style={{ width: `${pct}%` }}
/>
</div>
{showLabel && (
<span className="tabular w-9 shrink-0 text-right text-xs text-fg-muted">
{pct.toFixed(0)}%
</span>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function SectionTitle({
title,
description,
actions,
className,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-wrap items-end justify-between gap-3', className)}>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
{description !== undefined && (
<p className="mt-0.5 text-xs text-fg-muted">{description}</p>
)}
</div>
{actions !== undefined && (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
)}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useId } from 'react'
import type { ReactNode, SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { FIELD_BASE, FieldFrame } from './Input.tsx'
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
options: SelectOption[]
className?: string
selectClassName?: string
}
export function Select({
label,
hint,
error,
options,
className,
selectClassName,
id,
...props
}: SelectProps) {
const autoId = useId()
const selectId = id ?? autoId
const describedBy = error ? `${selectId}-error` : hint !== undefined ? `${selectId}-hint` : undefined
const select = (
<div className="relative">
<select
id={selectId}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'h-9 appearance-none py-0 pr-8 pl-2.5 text-sm',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown
size={15}
strokeWidth={2}
aria-hidden="true"
className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-fg-subtle"
/>
</div>
)
if (label === undefined && hint === undefined && error === undefined) return select
return (
<FieldFrame id={selectId} label={label} hint={hint} error={error}>
{select}
</FieldFrame>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from '../../lib/cn.ts'
/** A shimmer placeholder used while a panel's data is in flight. */
export function Skeleton({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={cn(
'relative overflow-hidden rounded-md bg-surface-3',
'after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/8 after:to-transparent',
className,
)}
/>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '../../lib/cn.ts'
const SIZES = {
sm: 'size-3 border',
md: 'size-4 border-2',
lg: 'size-6 border-2',
} as const
export function Spinner({ size = 'md', className }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
return (
<span
role="status"
aria-live="polite"
aria-label="Loading"
className={cn(
'inline-block shrink-0 animate-spin rounded-full border-current border-t-transparent opacity-70',
SIZES[size],
className,
)}
/>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Skeleton } from './Skeleton.tsx'
export type StatTone = 'default' | 'ok' | 'warn' | 'danger'
const TONE_TEXT: Record<StatTone, string> = {
default: 'text-fg',
ok: 'text-ok',
warn: 'text-warn',
danger: 'text-danger',
}
const TONE_ICON: Record<StatTone, string> = {
default: 'bg-surface-3 text-fg-muted',
ok: 'bg-ok-soft text-ok',
warn: 'bg-warn-soft text-warn',
danger: 'bg-danger-soft text-danger',
}
export function StatCard({
label,
value,
hint,
icon: Icon,
trend,
tone = 'default',
className,
loading,
}: {
label: ReactNode
value: ReactNode
hint?: ReactNode
icon?: LucideIcon
trend?: number
tone?: StatTone
className?: string
loading?: boolean
}) {
const TrendIcon = trend === undefined || trend === 0 ? Minus : trend > 0 ? ArrowUpRight : ArrowDownRight
// A rising block rate is good news; a rising error rate is not. The tone of
// the card wins, otherwise fall back to "up is good".
const trendTone = trend === undefined || trend === 0 ? 'text-fg-subtle' : (trend > 0) === (tone !== 'danger') ? 'text-ok' : 'text-danger'
return (
<div
className={cn(
'flex min-w-0 items-start gap-3 rounded-xl border border-line bg-surface p-4 shadow-panel',
className,
)}
>
{Icon && (
<span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', TONE_ICON[tone])}>
<Icon size={18} strokeWidth={2} aria-hidden="true" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium tracking-wide text-fg-muted uppercase">{label}</p>
{loading ? (
<Skeleton className="mt-2 h-7 w-24" />
) : (
<p className={cn('tabular mt-1 truncate text-2xl font-semibold', TONE_TEXT[tone])}>{value}</p>
)}
{(hint !== undefined || trend !== undefined) && (
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs">
{trend !== undefined && (
<span className={cn('tabular inline-flex items-center gap-0.5 font-medium', trendTone)}>
<TrendIcon size={14} aria-hidden="true" />
{Math.abs(trend).toFixed(1)}%
</span>
)}
{hint !== undefined && <span className="truncate text-fg-subtle">{hint}</span>}
</div>
)}
</div>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Switch({
checked,
onChange,
label,
description,
disabled = false,
className,
}: {
checked: boolean
onChange: (checked: boolean) => void
label?: ReactNode
description?: ReactNode
disabled?: boolean
className?: string
}) {
const control = (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={typeof label === 'string' ? label : undefined}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
checked ? 'border-accent-strong bg-accent-strong' : 'border-line-strong bg-surface-3',
className,
)}
>
<span
aria-hidden="true"
className={cn(
'absolute size-3.5 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
)}
/>
</button>
)
if (label === undefined && description === undefined) return control
return (
<label
className={cn(
'flex items-start justify-between gap-3',
disabled ? 'opacity-60' : 'cursor-pointer',
)}
>
<span className="flex min-w-0 flex-col gap-0.5">
{label !== undefined && <span className="text-sm text-fg">{label}</span>}
{description !== undefined && (
<span className="text-xs text-fg-muted">{description}</span>
)}
</span>
<span onClick={(e) => e.preventDefault()} className="mt-0.5 shrink-0">
{control}
</span>
</label>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export type TableAlign = 'left' | 'right' | 'center'
export interface TableColumn {
key: string
header: ReactNode
align?: TableAlign
width?: string
className?: string
}
const ALIGN: Record<TableAlign, string> = {
left: 'text-left',
right: 'text-right',
center: 'text-center',
}
/**
* A plain data table: the caller owns the cell contents, so a cell is either a
* ready-made node (a Badge, a switch) or a raw value it can format.
*/
export function Table({
columns,
rows,
rowKey,
empty = 'Nothing to show',
loading = false,
onRowClick,
maxHeight,
className,
}: {
columns: TableColumn[]
rows: Record<string, unknown>[]
rowKey: (row: Record<string, unknown>) => string
empty?: ReactNode
loading?: boolean
onRowClick?: (row: Record<string, unknown>) => void
maxHeight?: number | string
className?: string
}) {
const [hovered, setHovered] = useState<string | null>(null)
const maxHeightStyle =
typeof maxHeight === 'number' ? { maxHeight: `${maxHeight}px` } : maxHeight ? { maxHeight } : undefined
if (rows.length === 0 && !loading) {
return (
<div
className={cn(
'flex items-center justify-center rounded-lg border border-dashed border-line p-8 text-sm text-fg-subtle',
className,
)}
>
{empty}
</div>
)
}
return (
<div
className={cn('relative overflow-auto rounded-lg border border-line', className)}
style={maxHeightStyle}
>
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="border-b border-line">
{columns.map((column) => (
<th
key={column.key}
scope="col"
style={column.width ? { width: column.width } : undefined}
className={cn(
'px-3 py-2 text-xs font-semibold tracking-wide text-fg-muted uppercase',
ALIGN[column.align ?? 'left'],
column.className,
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const key = rowKey(row)
const clickable = onRowClick !== undefined
return (
<tr
key={key}
onClick={clickable ? () => onRowClick(row) : undefined}
onMouseEnter={clickable ? () => setHovered(key) : undefined}
onMouseLeave={clickable ? () => setHovered(null) : undefined}
className={cn(
'border-b border-line last:border-0',
clickable && 'cursor-pointer',
hovered === key ? 'bg-surface-3' : 'hover:bg-surface-2',
)}
>
{columns.map((column) => {
const value = row[column.key]
return (
<td
key={column.key}
className={cn(
'px-3 py-2 align-middle text-fg',
ALIGN[column.align ?? 'left'],
column.align === 'right' && 'tabular',
column.className,
)}
>
{value === null || value === undefined || value === ''
? '—'
: isRenderable(value)
? value
: String(value)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
{loading && (
<div className="absolute inset-0 grid place-items-center bg-surface/60 backdrop-blur-[1px]">
<span className="rounded-md border border-line bg-surface-2 px-2 py-1 text-xs text-fg-muted">
Loading…
</span>
</div>
)}
</div>
)
}
/** React elements and arrays render as-is; a plain object does not. */
function isRenderable(value: unknown): value is ReactNode {
if (Array.isArray(value)) return true
if (typeof value === 'object' && value !== null) {
return typeof (value as { $$typeof?: unknown }).$$typeof === 'symbol'
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export interface TabItem {
id: string
label: string
count?: number
icon?: LucideIcon
}
export function Tabs({
tabs,
value,
onChange,
className,
}: {
tabs: TabItem[]
value: string
onChange: (id: string) => void
className?: string
}) {
return (
<div
role="tablist"
className={cn(
'inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-line bg-surface-2 p-1',
className,
)}
>
{tabs.map((tab) => {
const active = tab.id === value
const Icon = tab.icon
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.id)}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
)}
>
{Icon && <Icon size={14} strokeWidth={2} aria-hidden="true" />}
{tab.label}
{tab.count !== undefined && (
<span
className={cn(
'tabular rounded px-1 py-px text-[10px]',
active ? 'bg-accent-soft text-accent' : 'bg-surface-3 text-fg-subtle',
)}
>
{tab.count}
</span>
)}
</button>
)
})}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useId } from 'react'
import type { ReactNode, TextareaHTMLAttributes } from 'react'
import { cn } from '../../lib/cn.ts'
import { FIELD_BASE, FieldFrame } from './Input.tsx'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
monospace?: boolean
className?: string
}
export function Textarea({
label,
hint,
error,
monospace = false,
className,
id,
rows = 6,
...props
}: TextareaProps) {
const autoId = useId()
const fieldId = id ?? autoId
const describedBy = error ? `${fieldId}-error` : hint !== undefined ? `${fieldId}-hint` : undefined
const textarea = (
<textarea
id={fieldId}
rows={rows}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'resize-y px-2.5 py-2 text-sm leading-relaxed',
monospace && 'font-mono text-xs',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
/>
)
if (label === undefined && hint === undefined && error === undefined) return textarea
return (
<FieldFrame id={fieldId} label={label} hint={hint} error={error}>
{textarea}
</FieldFrame>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export type ToastTone = 'default' | 'ok' | 'warn' | 'danger'
export interface ToastOptions {
title: string
description?: string
tone?: ToastTone
durationMs?: number
}
interface ToastItem extends ToastOptions {
id: number
tone: ToastTone
durationMs: number
}
interface ToastContextValue {
toasts: ToastItem[]
toast: (options: ToastOptions) => void
dismiss: (id: number) => void
claimToaster: () => boolean
}
const ToastContext = createContext<ToastContextValue | null>(null)
const TONE_STYLES: Record<ToastTone, string> = {
default: 'border-line bg-surface-3',
ok: 'border-ok/40 bg-ok-soft',
warn: 'border-warn/40 bg-warn-soft',
danger: 'border-danger/40 bg-danger-soft',
}
const TONE_ICON: Record<ToastTone, typeof Info> = {
default: Info,
ok: CheckCircle2,
warn: AlertTriangle,
danger: XCircle,
}
let nextId = 1
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
const claimed = useRef(false)
useEffect(() => {
const pending = timers.current
return () => {
for (const timer of pending.values()) clearTimeout(timer)
pending.clear()
}
}, [])
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.current.delete(id)
}
setToasts((current) => current.filter((item) => item.id !== id))
}, [])
const toast = useCallback(
(options: ToastOptions) => {
const item: ToastItem = {
tone: 'default',
durationMs: 5000,
...options,
id: nextId++,
}
// Cap the stack: a failing refresh loop must not bury the screen.
setToasts((current) => [...current, item].slice(-5))
if (item.durationMs > 0) {
timers.current.set(item.id, setTimeout(() => dismiss(item.id), item.durationMs))
}
},
[dismiss],
)
const claimToaster = useCallback(() => {
if (claimed.current) return false
claimed.current = true
return true
}, [])
const value = useMemo<ToastContextValue>(
() => ({ toasts, toast, dismiss, claimToaster }),
[toasts, toast, dismiss, claimToaster],
)
return (
<ToastContext.Provider value={value}>
{children}
<Toaster />
</ToastContext.Provider>
)
}
const NOOP = {
toast: () => {},
dismiss: () => {},
}
/**
* Fires a toast. Outside a provider it degrades to a no-op instead of
* throwing: a notification is never a reason for a page to fail to render.
*/
export function useToast(): { toast: (options: ToastOptions) => void } {
const ctx = useContext(ToastContext)
if (ctx === null) return NOOP
return { toast: ctx.toast }
}
/**
* Renders the stack bottom-right in a portal on <body>. The provider already
* mounts one; a hand-placed extra <Toaster/> is ignored so the DOM never
* doubles up.
*/
export function Toaster() {
const ctx = useContext(ToastContext)
const [owns] = useState(() => (ctx === null ? false : ctx.claimToaster()))
if (ctx === null || !owns || ctx.toasts.length === 0) return null
if (typeof document === 'undefined') return null
return createPortal(
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed right-4 bottom-4 z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
>
{ctx.toasts.map((item) => {
const Icon = TONE_ICON[item.tone]
return (
<div
key={item.id}
className={cn(
'animate-slide-up pointer-events-auto flex items-start gap-2.5 rounded-lg border p-3 shadow-panel',
TONE_STYLES[item.tone],
)}
>
<Icon
size={16}
strokeWidth={2}
className="mt-0.5 shrink-0 text-fg-muted"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">{item.title}</p>
{item.description !== undefined && (
<p className="mt-0.5 text-xs break-words text-fg-muted">{item.description}</p>
)}
</div>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => ctx.dismiss(item.id)}
className="shrink-0 rounded-md p-0.5 text-fg-subtle transition-colors hover:bg-surface-3 hover:text-fg"
>
<X size={14} strokeWidth={2} aria-hidden="true" />
</button>
</div>
)
})}
</div>,
document.body,
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
const SIDES = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
} as const
/**
* Hover/focus tooltip built from one wrapper element. No positioning library
* and no portal: the console has no tooltip that needs to escape an
* overflow container, and CSS keeps it working in a screenshot test.
*/
export function Tooltip({
content,
children,
side = 'top',
className,
}: {
content: ReactNode
children: ReactNode
side?: 'top' | 'bottom' | 'left' | 'right'
className?: string
}) {
if (content === undefined || content === null || content === '') return <>{children}</>
return (
<span className={cn('group/tt relative inline-flex', className)}>
{children}
<span
role="tooltip"
className={cn(
'pointer-events-none absolute z-50 hidden w-max max-w-64 rounded-md border border-line bg-surface-3 px-2 py-1 text-xs font-normal text-fg shadow-panel',
'animate-fade-in group-hover/tt:block group-focus-within/tt:block',
SIDES[side],
)}
>
{content}
</span>
</span>
)
}
+36
View File
@@ -0,0 +1,36 @@
/** Every primitive the pages are allowed to reach for. */
export { Card, CardBody, CardFooter, CardHeader, CardTitle } from './Card.tsx'
export { StatCard } from './StatCard.tsx'
export { Button } from './Button.tsx'
export { IconButton } from './IconButton.tsx'
export { Input } from './Input.tsx'
export { Textarea } from './Textarea.tsx'
export { Select } from './Select.tsx'
export { Switch } from './Switch.tsx'
export { Badge } from './Badge.tsx'
export { Table } from './Table.tsx'
export { Dialog } from './Dialog.tsx'
export { ConfirmDialog } from './ConfirmDialog.tsx'
export { Tooltip } from './Tooltip.tsx'
export { ToastProvider, Toaster, useToast } from './Toast.tsx'
export { Skeleton } from './Skeleton.tsx'
export { EmptyState } from './EmptyState.tsx'
export { Tabs } from './Tabs.tsx'
export { Spinner } from './Spinner.tsx'
export { Progress } from './Progress.tsx'
export { Pagination } from './Pagination.tsx'
export { Kbd } from './Kbd.tsx'
export { CopyButton } from './CopyButton.tsx'
export { SectionTitle } from './SectionTitle.tsx'
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx'
export type { InputProps } from './Input.tsx'
export type { TextareaProps } from './Textarea.tsx'
export type { SelectOption, SelectProps } from './Select.tsx'
export type { TabItem } from './Tabs.tsx'
export type { TableColumn } from './Table.tsx'
export type { ToastOptions, ToastTone } from './Toast.tsx'
export type { BadgeTone } from './Badge.tsx'
export type { IconButtonProps } from './IconButton.tsx'
export type { StatTone } from './StatCard.tsx'
export type { ProgressTone } from './Progress.tsx'
+107
View File
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface ApiState<T> {
data: T | null
error: string | null
loading: boolean
refetch: () => void
}
function message(err: unknown): string {
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
return 'request failed'
}
/**
* Runs `fn` on mount and whenever `deps` change, aborting the previous request
* so a fast filter change cannot let a slow earlier response win a race.
*/
export function useApi<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [nonce, setNonce] = useState(0)
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
const controller = new AbortController()
let live = true
setLoading(true)
fnRef
.current()
.then((value) => {
if (!live) return
setData(value)
setError(null)
})
.catch((err: unknown) => {
if (!live) return
if (err instanceof DOMException && err.name === 'AbortError') return
setError(message(err))
})
.finally(() => {
if (!live) return
setLoading(false)
})
return () => {
live = false
controller.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce])
const refetch = useCallback(() => setNonce((n) => n + 1), [])
return { data, error, loading, refetch }
}
export interface AsyncAction<TArgs extends unknown[], T> {
run: (...args: TArgs) => Promise<T | null>
pending: boolean
error: string | null
clearError: () => void
}
/**
* A fire-and-forget mutation: `run` never rejects, it resolves to `null` on
* failure and puts the message in `error` so the caller can just toast it.
*/
export function useAsyncAction<TArgs extends unknown[], T>(
fn: (...args: TArgs) => Promise<T>,
): AsyncAction<TArgs, T> {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const mounted = useRef(true)
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const run = useCallback(async (...args: TArgs): Promise<T | null> => {
setPending(true)
setError(null)
try {
const result = await fnRef.current(...args)
return result
} catch (err) {
if (mounted.current) setError(message(err))
return null
} finally {
if (mounted.current) setPending(false)
}
}, [])
const clearError = useCallback(() => setError(null), [])
return { run, pending, error, clearError }
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
/**
* Polls `fn` every `intervalMs`, but only while the tab is visible: a hidden
* dashboard tab burning CPU on a node nobody is looking at is pure waste. The
* first tick after the tab becomes visible is immediate, so the numbers are
* never stale on return.
*/
export function useAutoRefresh(fn: () => void, intervalMs: number, enabled = true): void {
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
if (!enabled || intervalMs <= 0) return
if (typeof document === 'undefined') return
let timer: ReturnType<typeof setInterval> | undefined
const start = (): void => {
if (timer !== undefined) return
if (document.hidden) {
// Hidden: no timer at all. The visibility listener restarts it.
return
}
timer = setInterval(() => {
if (!document.hidden) fnRef.current()
}, intervalMs)
}
const stop = (): void => {
if (timer !== undefined) {
clearInterval(timer)
timer = undefined
}
}
const onVisibility = (): void => {
if (document.hidden) {
stop()
return
}
start()
fnRef.current()
}
start()
document.addEventListener('visibilitychange', onVisibility)
return () => {
stop()
document.removeEventListener('visibilitychange', onVisibility)
}
}, [intervalMs, enabled])
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
/**
* Trails `value` by `ms`. Used by the search boxes so typing does not fire a
* request per keystroke.
*/
export function useDebounced<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
if (ms <= 0) {
setDebounced(value)
return
}
const timer = setTimeout(() => setDebounced(value), ms)
return () => clearTimeout(timer)
}, [value, ms])
return debounced
}
+44
View File
@@ -0,0 +1,44 @@
import { useState } from 'react'
export type TimeRange = '15m' | '1h' | '6h' | '24h'
const RANGE_MS: Record<TimeRange, number> = {
'15m': 15 * 60_000,
'1h': 60 * 60_000,
'6h': 6 * 60 * 60_000,
'24h': 24 * 60 * 60_000,
}
/** Roughly 40-60 bars per chart, which is where recharts stays readable. */
const RANGE_BUCKETS: Record<TimeRange, number> = {
'15m': 30,
'1h': 60,
'6h': 72,
'24h': 96,
}
export const TIME_RANGES: TimeRange[] = ['15m', '1h', '6h', '24h']
export interface TimeRangeState {
range: TimeRange
setRange: (range: TimeRange) => void
rangeMs: number
buckets: number
/** ISO timestamp for the `since` query parameter. */
since: () => string
}
export function useTimeRange(defaultRange: TimeRange = '1h'): TimeRangeState {
const [range, setRange] = useState<TimeRange>(defaultRange)
const rangeMs = RANGE_MS[range]
const buckets = RANGE_BUCKETS[range]
return {
range,
setRange,
rangeMs,
buckets,
since: () => new Date(Date.now() - rangeMs).toISOString(),
}
}
+246
View File
@@ -0,0 +1,246 @@
@import "tailwindcss";
/* Dark-first operator console. Every colour the UI uses comes from the custom
properties below, so a theme flip is a single attribute on <html> and never
a re-render. Tailwind utilities read them through `@theme inline`, which
keeps the var() reference in the generated CSS instead of freezing the
value at build time. */
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
:root {
color-scheme: dark;
/* Surfaces, from furthest back to closest to the reader. */
--canvas: #0a0c10;
--surface: #11141a;
--surface-2: #161a21;
--surface-3: #1d222b;
--line: #242a34;
--line-strong: #333b49;
/* Text. */
--fg: #e7eaef;
--fg-muted: #98a2b3;
--fg-subtle: #6b7687;
/* One accent: emerald. */
--accent: #34d399;
--accent-strong: #10b981;
--accent-fg: #04120c;
--accent-soft: rgb(52 211 153 / 14%);
--accent-ring: rgb(52 211 153 / 45%);
/* Semantics. */
--ok: #34d399;
--ok-soft: rgb(52 211 153 / 14%);
--warn: #fbbf24;
--warn-soft: rgb(251 191 36 / 14%);
--danger: #f87171;
--danger-soft: rgb(248 113 113 / 14%);
--info: #60a5fa;
--info-soft: rgb(96 165 250 / 14%);
--shadow-panel: 0 10px 30px -12px rgb(0 0 0 / 70%);
--scroll-thumb: #2b323d;
--scroll-thumb-hover: #3a4351;
}
[data-theme="light"] {
color-scheme: light;
--canvas: #f4f6f8;
--surface: #ffffff;
--surface-2: #ffffff;
--surface-3: #eef1f5;
--line: #e3e7ed;
--line-strong: #cbd2dc;
--fg: #0f141b;
--fg-muted: #55616f;
--fg-subtle: #7b8794;
--accent: #059669;
--accent-strong: #047857;
--accent-fg: #ffffff;
--accent-soft: rgb(5 150 105 / 10%);
--accent-ring: rgb(5 150 105 / 40%);
--ok: #059669;
--ok-soft: rgb(5 150 105 / 12%);
--warn: #b45309;
--warn-soft: rgb(180 83 9 / 12%);
--danger: #dc2626;
--danger-soft: rgb(220 38 38 / 10%);
--info: #2563eb;
--info-soft: rgb(37 99 235 / 10%);
--shadow-panel: 0 10px 30px -14px rgb(15 23 42 / 25%);
--scroll-thumb: #cbd2dc;
--scroll-thumb-hover: #aab3c0;
}
@theme inline {
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-accent-fg: var(--accent-fg);
--color-accent-soft: var(--accent-soft);
--color-ok: var(--ok);
--color-ok-soft: var(--ok-soft);
--color-warn: var(--warn);
--color-warn-soft: var(--warn-soft);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-info: var(--info);
--color-info-soft: var(--info-soft);
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--font-mono:
ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas,
"Liberation Mono", monospace;
--animate-fade-in: fade-in 180ms ease-out both;
--animate-slide-up: slide-up 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-pulse-soft: pulse-soft 2.4s ease-in-out infinite;
--animate-shimmer: shimmer 1.6s linear infinite;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes pulse-soft {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
}
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
background-color: var(--canvas);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fg);
}
:focus-visible {
outline: 2px solid var(--accent-ring);
outline-offset: 2px;
}
::selection {
background-color: var(--accent-soft);
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;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--scroll-thumb);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--scroll-thumb-hover);
}
::-webkit-scrollbar-corner {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
+546
View File
@@ -0,0 +1,546 @@
/**
* Typed client for the fancy-dns admin API.
*
* Every path is relative (`/api/...`) because the Go binary serves the built
* assets and the API from the same origin; in dev Vite proxies `/api` to the
* local daemon.
*/
import type {
BlocklistsResponse,
CacheResponse,
CacheTopResponse,
ClientsResponse,
ClientGroup,
Dashboard,
DefaultPolicy,
DomainAction,
DomainCheck,
DomainFilters,
DomainsResponse,
LogFilters,
LogItem,
LoginResponse,
LogsResponse,
LiveStats,
OkResponse,
SaveSettingsResponse,
SettingsResponse,
UpstreamBootstrap,
UpstreamPolicy,
UpstreamServer,
UpstreamsResponse,
ValidateResponse,
} from './types.ts'
const TOKEN_KEY = 'fancydns.token'
/** An HTTP-level failure. `body` is the raw response text for the logs. */
export class ApiError extends Error {
status: number
body: string
constructor(status: number, message: string, body = '') {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
export function getToken(): string {
try {
return localStorage.getItem(TOKEN_KEY) ?? ''
} catch {
// Private mode / disabled storage: behave as an anonymous session.
return ''
}
}
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token)
} catch {
/* non-fatal: the session just does not survive a reload */
}
}
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
/* ignore */
}
}
/** Types the API returns for endpoints the shared types.ts does not name. */
export interface WhoamiResponse {
name: string
mode: string
read_only: boolean
can_write: boolean
auth_mode: string
base_path: string
}
export interface LoginInput {
api_key?: string
username?: string
password?: string
}
export interface RefreshBlocklistsResponse {
started: boolean
names: string[]
}
export interface ToggleBlocklistResponse {
ok: boolean
name: string
enabled: boolean
}
export interface AddDomainInput {
name: string
action: DomainAction | string
list?: string
source?: string
content?: string
}
/** A lookup failure comes back 200 with an `error` string, not a 4xx. */
export type DomainCheckResponse = DomainCheck & { error?: string }
export interface ClientsPayload {
groups: ClientGroup[]
default_policy: DefaultPolicy | string
}
export interface UpstreamsPayload {
policy: UpstreamPolicy | string
servers: UpstreamServer[]
bootstraps: UpstreamBootstrap[]
healthcheck_interval_s?: number
max_attempts?: number
breaker_threshold?: number
}
export interface SaveResponse {
ok: boolean
version: string
warnings?: string[]
}
export interface FlushCacheResponse {
ok: boolean
entries: number
}
export interface ReloadSettingsResponse {
ok: boolean
version: string
}
export type LogStreamHandlers = {
onEntry: (entry: LogItem) => void
onStats: (stats: LiveStats) => void
onReady: (info: { at?: string; version?: string }) => void
onError: (message: string) => void
onOpen: () => void
}
/** Query values the server should not see at all. */
type ParamValue = string | number | boolean | null | undefined
/** Builds `/api/thing?a=1&b=2`, dropping empty values. */
export function apiUrl(path: string, params?: Record<string, ParamValue>): string {
if (!params) return path
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value === '') continue
search.set(key, String(value))
}
const qs = search.toString()
return qs ? `${path}?${qs}` : path
}
/** One redirect per session, however many requests race into a 401. */
let redirecting = false
function sendToLogin(): void {
if (redirecting) return
redirecting = true
const next = typeof location === 'undefined' ? '' : location.pathname
location.assign(`/auth/login?next=${encodeURIComponent(next)}`)
}
function isLoginPath(path: string): boolean {
return path.includes('/auth/login')
}
async function errorMessage(res: Response): Promise<{ message: string; body: string }> {
let body = ''
try {
body = await res.text()
} catch {
body = ''
}
let message = body
if (body) {
try {
const parsed: unknown = JSON.parse(body)
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
const err = (parsed as { error: unknown }).error
if (typeof err === 'string' && err !== '') message = err
}
} catch {
/* not JSON: show the raw text */
}
}
if (message === '') message = res.statusText || `HTTP ${res.status}`
return { message, body }
}
/**
* `fetch` with JSON conveniences, the API key header and a single 401
* redirect. Non-2xx always throws so callers can surface the message.
*/
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
const hasBody = init?.body !== undefined && init?.body !== null
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
let res: Response
try {
res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
} catch (err) {
// Network failure, DNS, offline, or an aborted request from a unmounted
// component: never surface AbortError noise to the user.
if (err instanceof DOMException && err.name === 'AbortError') throw err
const message = err instanceof Error ? err.message : 'network request failed'
throw new ApiError(0, `cannot reach the fancy-dns API: ${message}`)
}
if (res.status === 401) {
// A failed login is not a dead session: do not bounce the login page to
// itself.
if (!isLoginPath(path)) {
clearToken()
sendToLogin()
}
const { message, body } = await errorMessage(res)
throw new ApiError(401, message, body)
}
if (!res.ok) {
const { message, body } = await errorMessage(res)
throw new ApiError(res.status, message, body)
}
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
return undefined as T
}
const text = await res.text()
if (text === '') return undefined as T
try {
return JSON.parse(text) as T
} catch {
throw new ApiError(res.status, 'the API returned a malformed response', text)
}
}
/** Percent-encodes a single path segment. */
function seg(value: string): string {
return encodeURIComponent(value)
}
export async function login(body: LoginInput): Promise<LoginResponse> {
const res = await apiFetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(body),
})
if (res?.token) setToken(res.token)
return res
}
export async function logout(): Promise<OkResponse> {
try {
return await apiFetch<OkResponse>('/api/auth/logout', { method: 'POST' })
} finally {
clearToken()
}
}
export function whoami(): Promise<WhoamiResponse> {
return apiFetch<WhoamiResponse>('/api/whoami')
}
export function getDashboard(range: string): Promise<Dashboard> {
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
}
export function getBlocklists(): Promise<BlocklistsResponse> {
return apiFetch<BlocklistsResponse>('/api/blocklists')
}
export async function refreshBlocklists(names?: string[]): Promise<RefreshBlocklistsResponse> {
if (names && names.length > 0) {
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', {
method: 'POST',
body: JSON.stringify({ names }),
})
}
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', { method: 'POST' })
}
export function toggleBlocklist(
name: string,
enabled: boolean,
): Promise<ToggleBlocklistResponse> {
return apiFetch<ToggleBlocklistResponse>('/api/blocklists/toggle', {
method: 'POST',
body: JSON.stringify({ name, enabled }),
})
}
export function getDomains(filters: DomainFilters = {}): Promise<DomainsResponse> {
return apiFetch<DomainsResponse>(apiUrl('/api/domains', { ...filters }))
}
export function addDomain(input: AddDomainInput): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/domains', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function deleteDomain(name: string, action?: string, list?: string): Promise<OkResponse> {
return apiFetch<OkResponse>(apiUrl(`/api/domains/${seg(name)}`, { action, list }), {
method: 'DELETE',
})
}
export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
export function getClients(): Promise<ClientsResponse> {
return apiFetch<ClientsResponse>('/api/clients')
}
export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/clients', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getUpstreams(): Promise<UpstreamsResponse> {
return apiFetch<UpstreamsResponse>('/api/upstreams')
}
export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/upstreams', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
}
export function clearLogs(): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
}
export function getCache(): Promise<CacheResponse> {
return apiFetch<CacheResponse>('/api/cache')
}
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
}
export function flushCache(): Promise<FlushCacheResponse> {
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
}
export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings')
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function validateSettings(yaml: string): Promise<ValidateResponse> {
return apiFetch<ValidateResponse>('/api/settings/validate', {
method: 'POST',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function reloadSettings(): Promise<ReloadSettingsResponse> {
return apiFetch<ReloadSettingsResponse>('/api/settings/reload', { method: 'POST' })
}
const STREAM_PATH = '/api/logs/stream'
const RECONNECT_MIN_MS = 1000
const RECONNECT_MAX_MS = 15000
/**
* Subscribes to the live query log.
*
* Native `EventSource` cannot send the `X-API-Key` header and the daemon
* accepts no cookie, so the stream is read with `fetch` plus a minimal SSE
* parser. That costs a hand-rolled reconnect, which is what EventSource would
* have given us for free; the handler contract is unchanged.
*
* Returns a teardown function that is safe to call more than once.
*/
export function streamLogs(handlers: LogStreamHandlers): () => void {
const controller = new AbortController()
let stopped = false
let backoff = RECONNECT_MIN_MS
let timer: ReturnType<typeof setTimeout> | undefined
let attempt = 0
const open = (): void => {
if (stopped) return
void run()
}
const run = async (): Promise<void> => {
if (stopped) return
const token = getToken()
const headers = new Headers({ Accept: 'text/event-stream' })
if (token) headers.set('X-API-Key', token)
try {
const res = await fetch(STREAM_PATH, {
headers,
credentials: 'same-origin',
signal: controller.signal,
cache: 'no-store',
})
if (res.status === 401) {
clearToken()
sendToLogin()
handlers.onError('unauthorized: the live log stream needs a valid session')
return
}
if (!res.ok || !res.body) {
const message = res.ok
? 'the live log stream is not supported by this browser'
: `the live log stream failed with HTTP ${res.status}`
handlers.onError(message)
schedule()
return
}
attempt = 0
backoff = RECONNECT_MIN_MS
handlers.onOpen()
await readSse(res.body, handlers, controller.signal)
if (stopped) return
// A clean end of stream still means the connection is gone.
schedule()
} catch (err) {
if (stopped) return
if (controller.signal.aborted) return
const message = err instanceof Error ? err.message : 'the live log stream dropped'
handlers.onError(message)
schedule()
}
}
const schedule = (): void => {
if (stopped) return
attempt += 1
const delay = Math.min(RECONNECT_MAX_MS, backoff)
backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8))
timer = setTimeout(open, delay)
}
open()
return () => {
if (stopped) return
stopped = true
if (timer !== undefined) clearTimeout(timer)
controller.abort()
}
}
/** Splits an SSE byte stream into `event:`/`data:` frames. */
async function readSse(
body: ReadableStream<Uint8Array>,
handlers: LogStreamHandlers,
signal: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const handle = (frame: string): void => {
let event = 'message'
const data: string[] = []
for (const rawLine of frame.split('\n')) {
const line = rawLine.replace(/\r$/, '')
if (line === '' || line.startsWith(':')) continue
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
}
if (data.length === 0) return
let payload: unknown
try {
payload = JSON.parse(data.join('\n'))
} catch {
return
}
if (event === 'entry') {
handlers.onEntry(payload as LogItem)
} else if (event === 'stats') {
handlers.onStats(payload as LiveStats)
} else if (event === 'ready') {
handlers.onReady(payload as { at?: string; version?: string })
}
}
try {
for (;;) {
if (signal.aborted) return
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let split = buffer.indexOf('\n\n')
while (split !== -1) {
const frame = buffer.slice(0, split)
buffer = buffer.slice(split + 2)
handle(frame)
split = buffer.indexOf('\n\n')
}
}
if (buffer.trim() !== '') handle(buffer)
} finally {
try {
reader.releaseLock()
} catch {
/* already released by the abort */
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from 'clsx'
import type { ClassValue } from 'clsx'
/** Conditional className helper used by every component. */
export function cn(...values: ClassValue[]): string {
return clsx(values)
}
+168
View File
@@ -0,0 +1,168 @@
/** Consistent human formatting for every number, size and duration in the UI. */
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
/** 1_048_576 -> "1.0 MB". Decimal (1000) is used above MB like most disks do. */
export function formatBytes(bytes: number | null | undefined, digits = 1): string {
const n = typeof bytes === 'number' && Number.isFinite(bytes) ? bytes : 0
if (n <= 0) return '0 B'
const base = n < 1000 ? 1024 : 1000
let value = n
let unit = 0
while (value >= base && unit < BYTE_UNITS.length - 1) {
value /= base
unit += 1
}
const precision = unit === 0 ? 0 : value >= 100 ? 0 : digits
return `${value.toFixed(precision)} ${BYTE_UNITS[unit]}`
}
/** 1_234_567 -> "1,234,567". */
export function formatNumber(value: number | null | undefined): string {
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
return n.toLocaleString('en-US')
}
/** 0.9432 -> "94.3%". Accepts 0..1 (fractions) and 0..100 (percents). */
export function formatPercent(fraction: number | null | undefined, digits = 1): string {
const n = typeof fraction === 'number' && Number.isFinite(fraction) ? fraction : 0
const scaled = n > 1 ? n : n * 100
return `${scaled.toFixed(digits)}%`
}
/** 0.043 -> "4.3%" — for rates the API reports as 0..1. */
export function formatRate(fraction: number | null | undefined, digits = 1): string {
return formatPercent(fraction, digits)
}
/** Milliseconds: 0.42 -> "420 µs", 12.5 -> "12.5 ms", 2400 -> "2.40 s". */
export function formatMs(ms: number | null | undefined): string {
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
if (n <= 0) return '0 ms'
if (n < 1) return `${Math.round(n * 1000)} µs`
if (n < 1000) return `${n < 10 ? n.toFixed(1) : Math.round(n).toString()} ms`
return `${(n / 1000).toFixed(2)} s`
}
/** Seconds -> "3d 4h 12m". */
export function formatUptime(seconds: number | null | undefined): string {
const total = typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(0, seconds) : 0
if (total < 1) return '0s'
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = Math.floor(total % 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
if (minutes > 0) return `${minutes}m ${secs}s`
return `${secs}s`
}
/** Parses RFC3339 strings and epoch numbers (seconds or milliseconds). */
export function toDate(value: string | number | null | undefined): Date | null {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return null
const ms = value > 1e11 ? value : value * 1000
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? null : d
}
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
/** "3 minutes ago" / "in 2 hours". */
export function formatRelative(
value: string | number | null | undefined,
now: number = Date.now(),
): string {
const date = toDate(value)
if (!date) return 'never'
const diffSec = Math.round((date.getTime() - now) / 1000)
const abs = Math.abs(diffSec)
if (abs < 5) return 'just now'
if (abs < 60) return diffSec < 0 ? `${abs}s ago` : `in ${abs}s`
const mins = Math.round(abs / 60)
if (mins < 60) return diffSec < 0 ? `${mins}m ago` : `in ${mins}m`
const hours = Math.round(mins / 60)
if (hours < 24) return diffSec < 0 ? `${hours}h ago` : `in ${hours}h`
const days = Math.round(hours / 24)
if (days < 365) return diffSec < 0 ? `${days}d ago` : `in ${days}d`
const years = (days / 365).toFixed(1)
return diffSec < 0 ? `${years}y ago` : `in ${years}y`
}
/** "14:03:22" in the viewer's locale. */
export function formatClock(value: string | number | Date | null | undefined): string {
const date = value instanceof Date ? value : toDate(value ?? null)
if (!date) return '--:--:--'
return date.toLocaleTimeString('en-GB', { hour12: false })
}
/** "12 Mar 14:03:22". */
export function formatDateTime(value: string | number | null | undefined): string {
const date = toDate(value ?? null)
if (!date) return '—'
return `${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })} ${formatClock(
date,
)}`
}
/** Query/sec-ish compact form for chart axes. */
export function formatCompact(value: number | null | undefined): string {
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
const abs = Math.abs(n)
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`
return abs >= 10 ? String(Math.round(n)) : n.toFixed(abs >= 1 ? 1 : 2)
}
/** 3.42 -> "3.4" — used for latency bar labels. */
export function formatLatency(ms: number | null | undefined): string {
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
if (n <= 0) return '—'
return n < 10 ? n.toFixed(1) : String(Math.round(n))
}
/** DNS rcode number + short name, e.g. 3 -> "3 NXDOMAIN". */
export function formatRcode(rcode: number | string | null | undefined): string {
const code = typeof rcode === 'number' ? rcode : Number.parseInt(String(rcode ?? ''), 10)
if (!Number.isFinite(code)) return '—'
return `${code} ${RCODE_NAMES[code] ?? 'UNKNOWN'}`
}
export const RCODE_NAMES: Record<number, string> = {
0: 'NOERROR',
1: 'FORMERR',
2: 'SERVFAIL',
3: 'NXDOMAIN',
4: 'NOTIMP',
5: 'REFUSED',
6: 'YXDOMAIN',
7: 'YXRRSET',
8: 'NXRRSET',
9: 'NOTAUTH',
10: 'NOTZONE',
16: 'BADVERS',
17: 'BADKEY',
18: 'BADTIME',
19: 'BADMODE',
20: 'BADNAME',
21: 'BADALG',
22: 'BADTRUNC',
23: 'BADCOOKIE',
}
/** Splits a textarea into trimmed, non-empty lines. */
export function toLines(value: string): string[] {
return value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
/** Joins an array back into a textarea value. */
export function fromLines(lines: string[] | null | undefined): string {
if (!lines || lines.length === 0) return ''
return lines.join('\n')
}
+298
View File
@@ -0,0 +1,298 @@
/** API shapes, mirroring the Go JSON tags exactly. */
export type Health = 'ok' | 'degraded' | 'down'
export type UpstreamHealth = 'up' | 'down' | 'degraded'
export type LogAction = 'allow' | 'block' | 'cached' | 'error' | 'drop'
export type DomainAction = 'allow' | 'block'
export type ListFormat = 'auto' | 'hosts' | 'adblock' | 'plain' | 'rpz'
export type UpstreamProto = 'udp' | 'tcp' | 'tls'
export type UpstreamPolicy = 'parallel' | 'random' | 'failover'
export type GroupPolicy = 'allow' | 'block_only' | 'deny'
export type DefaultPolicy = 'allow' | 'deny' | 'block_only'
export interface SeriesPoint {
t: number
queries: number
blocked: number
errors: number
cache_hits: number
}
export interface TopBlockedDomain {
domain: string
count: number
list: string
}
export interface TopQueriedDomain {
domain: string
count: number
qtype: string
blocked: boolean
}
export interface UpstreamStat {
addr: string
proto: string
health: UpstreamHealth
latency_ms: number
fail_rate: number
queries: number
avg_ms: number
p95_ms: number
}
export interface Dashboard {
health: Health
version: string
node_id: string
uptime_seconds: number
queries_total: number
queries_per_sec: number
cache_hit_rate: number
blocked_total: number
blocked_pct: number
series: SeriesPoint[]
top_blocked: TopBlockedDomain[]
top_queried: TopQueriedDomain[]
upstreams: UpstreamStat[]
}
export interface BlocklistItem {
name: string
format: ListFormat | string
enabled: boolean
url: string
path: string
rules: number
bytes: number
updated_at: string
duration_ms: number
err: string
etag: string
categories: string[]
stale: boolean
last_modified: string
}
export interface BlocklistStats {
total: number
exact: number
subdomain_rules: number
root_rules: number
allow: number
block: number
duplicates: number
sources: number
built_at: string
}
export interface BlocklistsResponse {
items: BlocklistItem[]
stats: BlocklistStats
}
export interface DomainItem {
name: string
action: DomainAction | string
list: string
source: string
categories: string[]
hits: number
updated_at: string
}
export interface DomainsResponse {
items: DomainItem[]
total: number
page: number
per_page: number
}
export interface DomainCheck {
name: string
action: DomainAction | string
source: string
categories: string[]
hits: number
rule: string
matched_pattern: string
}
export interface ClientMatch {
cidrs: string[]
ptr_suffixes: string[]
tags: string[]
any: boolean
}
export interface ClientGroup {
name: string
comment: string
policy: GroupPolicy | string
match: ClientMatch
allow_domains: string[]
deny_domains: string[]
skip_blocklist: boolean
skip_cache: boolean
rate_limit: number
max_concurrent: number
log_queries: boolean
upstreams: string[]
}
export interface ClientsResponse {
groups: ClientGroup[]
default_policy: DefaultPolicy | string
}
export interface UpstreamServer {
addr: string
proto: UpstreamProto | string
server_name: string
weight: number
comment: string
health: UpstreamHealth
latency_ms: number
fail_rate: number
queries: number
avg_ms: number
p95_ms: number
breaker_open: boolean
}
export interface UpstreamBootstrap {
name: string
addresses: string[]
refresh: string
}
export interface UpstreamsResponse {
policy: UpstreamPolicy | string
servers: UpstreamServer[]
bootstraps: UpstreamBootstrap[]
healthcheck_interval: string
max_attempts: number
breaker_threshold: number
}
export interface LogItem {
id: number | string
ts: string
client: string
client_group: string
name: string
qtype: string
rcode: number
action: LogAction | string
response: string
duration_ms: number
upstream: string
list: string
}
export interface LogsResponse {
items: LogItem[]
total: number
offset: number
}
export interface LiveStats {
qps: number
blocked_pct: number
cache_hit_rate: number
}
export interface CachePersisted {
enabled: boolean
path: string
bytes: number
entries: number
last_snapshot: string
writes: number
}
export interface CacheResponse {
enabled: boolean
size: number
entries: number
shards: number
min_ttl_ms: number
max_ttl_ms: number
serve_stale_ms: number
negative_ttl_ms: number
hit_rate: number
hits: number
misses: number
stale_hits: number
evictions: number
persisted: CachePersisted
}
export interface CacheTopEntry {
name: string
qtype: string
count: number
hits: number
last_seen: string
}
export interface CacheTopResponse {
items: CacheTopEntry[]
}
export interface SettingsResponse {
version: string
path: string
config_yaml: string
effective_yaml: string
defaults_yaml: string
last_error: string
reload_available: boolean
writable: boolean
}
export interface SaveSettingsResponse {
ok: boolean
version: string
warnings: string[]
}
export interface ConfigIssue {
field: string
message: string
}
export interface ValidateResponse {
ok: boolean
errors: ConfigIssue[]
warnings: ConfigIssue[]
}
export interface OkResponse {
ok: boolean
}
export interface LoginResponse {
ok: boolean
token?: string
}
export interface DomainFilters {
q?: string
action?: string
list?: string
page?: number
per_page?: number
}
export interface LogFilters {
limit?: number
offset?: number
q?: string
action?: string
client?: string
rcode?: string
blocked?: boolean
since?: string
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+342
View File
@@ -0,0 +1,342 @@
/**
* Blocklists — the compiled rule sources.
*
* Two read paths (`/api/blocklists`) and three mutations (toggle, refresh one,
* refresh all). Toggles are optimistic because the endpoint is a single write
* to an in-memory flag: the switch has to feel instant or it feels broken.
*/
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api'
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
import { useDebounced } from '../../hooks/useDebounced'
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format'
import {
Badge,
Button,
Card,
CardBody,
EmptyState,
IconButton,
Input,
StatCard,
Switch,
Table,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const EMPTY_STATS: BlocklistStats = {
total: 0,
exact: 0,
subdomain_rules: 0,
root_rules: 0,
allow: 0,
block: 0,
duplicates: 0,
sources: 0,
built_at: '',
}
type CheckView = Partial<DomainCheck> & {
list?: string
pattern?: string
error?: string
/** Older daemons called the matched rule "rule". */
rule?: string
matched_pattern?: string
}
/** The lookup endpoint answers 200 with an `error` string, not a 4xx. */
function checkView(value: unknown): CheckView | null {
if (!value || typeof value !== 'object') return null
return value as CheckView
}
function truncate(value: string, max = 42): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
}
export default function Blocklists() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
const [probe, setProbe] = useState('')
const debouncedProbe = useDebounced(probe, 300)
const name = debouncedProbe.trim()
const { data: checked, error: checkError, loading: checking } = useApi(
() => (name.length > 0 ? checkDomain(name) : Promise.resolve(null)),
[name],
)
const result = checkView(checked)
const stats = (data?.stats as BlocklistStats | undefined) ?? EMPTY_STATS
const items: BlocklistItem[] = useMemo(() => data?.items ?? [], [data])
const compiledAgo = useMemo(() => {
const built = toDate(stats.built_at)
if (built) return formatRelative(built.toISOString())
const newest = items
.map((i) => toDate(i.updated_at))
.filter((d): d is Date => d !== null)
.sort((a, b) => b.getTime() - a.getTime())[0]
return newest ? formatRelative(newest.toISOString()) : 'never'
}, [stats.built_at, items])
const toggle = useAsyncAction(async (list: string, enabled: boolean) =>
toggleBlocklist(list, enabled),
)
const refresh = useAsyncAction(async (names?: string[]) => refreshBlocklists(names))
const onToggle = async (item: BlocklistItem, next: boolean): Promise<void> => {
setOverrides((prev) => ({ ...prev, [item.name]: next }))
const applied = await toggle.run(item.name, next)
if (applied === null) {
setOverrides((prev) => {
const copy = { ...prev }
delete copy[item.name]
return copy
})
notify(toast, 'fail', toggle.error ?? `could not toggle ${item.name}`)
return
}
refetch()
}
const onRefresh = async (names?: string[]): Promise<void> => {
const key = names?.join(',') ?? ''
setPendingRows((prev) => ({ ...prev, [key]: true }))
const res = await refresh.run(names)
setPendingRows((prev) => ({ ...prev, [key]: false }))
if (res === null) {
notify(toast, 'fail', refresh.error ?? 'blocklist refresh failed')
return
}
notify(
toast,
'ok',
names && names.length > 0
? `refresh started for ${names.join(', ')}`
: 'refresh started for every source',
)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'List', width: '22%' },
{ key: 'format', header: 'Format', width: '10%' },
{ key: 'enabled', header: 'Enabled', width: '10%' },
{ key: 'rules', header: 'Rules', width: '9%', align: 'right' as const },
{ key: 'size', header: 'Size', width: '9%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '12%' },
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
{ key: 'source', header: 'Source', width: '24%' },
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
]
const rows: Row[] = items.map((item) => {
const enabled = overrides[item.name] ?? item.enabled
const busy = pendingRows[item.name] === true
return {
__key: item.name,
name: (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.name}</span>
{item.err ? (
<span className="truncate text-xs text-red-600 dark:text-red-400">{item.err}</span>
) : null}
</span>
),
format: <Badge tone="muted">{item.format}</Badge>,
enabled: (
<Switch
checked={enabled}
onChange={(next) => void onToggle(item, next)}
label={enabled ? 'on' : 'off'}
/>
),
rules: <span className="font-mono text-xs">{formatNumber(item.rules)}</span>,
size: <span className="font-mono text-xs">{formatBytes(item.bytes)}</span>,
updated: (
<span className="flex items-center gap-1.5 whitespace-nowrap text-xs">
{formatRelative(item.updated_at)}
{item.stale ? <Badge tone="warn">stale</Badge> : null}
</span>
),
duration: <span className="font-mono text-xs">{formatMs(item.duration_ms)}</span>,
source: (
<span
className="block max-w-[240px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
title={item.url || item.path}
>
{truncate(item.url || item.path)}
</span>
),
refresh: (
<IconButton
icon={<RefreshCw size={14} />}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy}
onClick={() => void onRefresh([item.name])}
/>
),
}
})
const action = result?.action ?? null
const matchedList = result?.source || result?.list || ''
const matchedPattern = result?.matched_pattern || result?.rule || result?.pattern || ''
return (
<div className="space-y-4">
<PageHeader
title="Blocklists"
description="Compiled rule sources, their freshness and their health."
actions={
<Button
icon={<RefreshCw size={14} />}
onClick={() => void onRefresh()}
loading={refresh.pending}
>
Refresh all
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} />
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
<StatCard
label="Allow rules"
value={formatNumber(stats.allow)}
icon={<ShieldCheck size={16} />}
tone={stats.allow > 0 ? 'default' : 'warn'}
/>
<StatCard
label="Duplicates"
value={formatNumber(stats.duplicates)}
icon={<Split size={16} />}
tone={stats.duplicates > 0 ? 'warn' : 'ok'}
/>
<StatCard
label="Last compile"
value={compiledAgo}
icon={<Clock size={16} />}
hint={stats.built_at ? undefined : 'no build recorded'}
/>
</div>
<Card
title="Test a domain"
subtitle="How would the current blocklists treat this name? Nothing is changed."
>
<CardBody>
<div className="flex flex-col gap-3 md:flex-row md:items-start">
<div className="w-full md:max-w-sm">
<Input
label="Domain"
placeholder="ads.example.com"
value={probe}
onChange={(e) => setProbe(e.target.value)}
icon={<ShieldCheck size={14} />}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="min-w-0 flex-1">
{!name ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Type a name to see the matching rule, the list it came from and how often it
fired.
</p>
) : checking ? (
<p className="text-sm text-slate-500">Checking {name}…</p>
) : checkError ? (
<p className="text-sm text-red-600 dark:text-red-400">{checkError}</p>
) : result?.error ? (
<p className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertTriangle size={14} aria-hidden="true" />
{result.error}
</p>
) : result ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge tone={action === 'allow' ? 'default' : 'warn'}>{action ?? 'unknown'}</Badge>
<span className="font-mono text-xs">{name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
list: <span className="font-mono">{matchedList || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
rule: <span className="font-mono">{matchedPattern || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
hits: <span className="font-mono">{formatNumber(result.hits ?? 0)}</span>
</span>
{Array.isArray(result.categories) && result.categories.length > 0 ? (
<span className="flex flex-wrap gap-1">
{result.categories.map((c) => (
<Badge key={c} tone="info">
{c}
</Badge>
))}
</span>
) : null}
</div>
) : (
<p className="text-sm text-slate-500">No answer for {name}.</p>
)}
</div>
</div>
</CardBody>
</Card>
<Card title="Sources" subtitle={`${items.length} configured`} padded={false}>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading && items.length === 0}
empty={
<EmptyState
title="No blocklists configured"
description="Add list sources in the configuration to compile rules."
/>
}
/>
</div>
</Card>
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Trash2 size={12} aria-hidden="true" />
Removing a list is a configuration change: delete its source in Settings, then refresh.
</p>
</div>
)
}
+558
View File
@@ -0,0 +1,558 @@
/**
* Clients — per-client ACL groups.
*
* The whole file is rewritten on every save (`PUT /api/clients`), so the editor
* works on a local draft and only commits on Save. A 400 comes back with the
* offending field paths, which are mapped back onto the draft fields so the
* user sees what is wrong next to the input rather than in a toast alone.
*/
import { useMemo, useState } from 'react'
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { ApiError, getClients, putClients } from '../../lib/api'
import type { ClientGroup, ClientMatch } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { fromLines, toLines } from '../../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Switch,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const POLICIES = [
{ value: 'allow', label: 'Allow — resolve everything' },
{ value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
{ value: 'deny', label: 'Deny — refuse everything' },
]
const DEFAULT_POLICIES = [
{ value: 'allow', label: 'Allow' },
{ value: 'block_only', label: 'Block only' },
{ value: 'deny', label: 'Deny' },
]
/** Textarea-friendly draft: numbers stay strings until they are parsed. */
type GroupDraft = {
name: string
comment: string
policy: string
cidrs: string
ptr_suffixes: string
tags: string
any: boolean
allow_domains: string
deny_domains: string
skip_blocklist: boolean
skip_cache: boolean
log_queries: boolean
rate_limit: string
max_concurrent: string
upstreams: string
}
const EMPTY_DRAFT: GroupDraft = {
name: '',
comment: '',
policy: 'allow',
cidrs: '',
ptr_suffixes: '',
tags: '',
any: false,
allow_domains: '',
deny_domains: '',
skip_blocklist: false,
skip_cache: false,
log_queries: false,
rate_limit: '0',
max_concurrent: '0',
upstreams: '',
}
function toNumber(value: string): number {
const n = Number.parseInt(value.trim(), 10)
return Number.isFinite(n) && n > 0 ? n : 0
}
function matchOf(g: ClientGroup): ClientMatch {
const m = g.match
return {
cidrs: m?.cidrs ?? [],
ptr_suffixes: m?.ptr_suffixes ?? [],
tags: m?.tags ?? [],
any: m?.any ?? false,
}
}
/** One line describing what the group matches, e.g. "10.0.0.0/8, .lan · *". */
function matchSummary(g: ClientGroup): string {
const m = matchOf(g)
const bits: string[] = []
if (m.any) bits.push('any client')
if (m.cidrs.length > 0) {
const shown = m.cidrs.slice(0, 2).join(', ')
bits.push(m.cidrs.length > 2 ? `${shown} +${m.cidrs.length - 2}` : shown)
}
if (m.ptr_suffixes.length > 0) bits.push(m.ptr_suffixes.join(', '))
if (m.tags.length > 0) bits.push(m.tags.map((t) => `#${t}`).join(' '))
if (bits.length === 0) return 'matches nothing — this group is inactive'
return bits.join(' · ')
}
function fromGroup(g: ClientGroup): GroupDraft {
const m = matchOf(g)
return {
name: g.name ?? '',
comment: g.comment ?? '',
policy: String(g.policy ?? 'allow'),
cidrs: fromLines(m.cidrs),
ptr_suffixes: fromLines(m.ptr_suffixes),
tags: fromLines(m.tags),
any: m.any,
allow_domains: fromLines(g.allow_domains),
deny_domains: fromLines(g.deny_domains),
skip_blocklist: g.skip_blocklist === true,
skip_cache: g.skip_cache === true,
log_queries: g.log_queries === true,
rate_limit: String(g.rate_limit ?? 0),
max_concurrent: String(g.max_concurrent ?? 0),
upstreams: fromLines(g.upstreams),
}
}
function toGroup(d: GroupDraft): ClientGroup {
return {
name: d.name.trim(),
comment: d.comment,
policy: d.policy,
match: {
cidrs: toLines(d.cidrs),
ptr_suffixes: toLines(d.ptr_suffixes),
tags: toLines(d.tags),
any: d.any,
},
allow_domains: toLines(d.allow_domains),
deny_domains: toLines(d.deny_domains),
skip_blocklist: d.skip_blocklist,
skip_cache: d.skip_cache,
rate_limit: toNumber(d.rate_limit),
max_concurrent: toNumber(d.max_concurrent),
log_queries: d.log_queries,
upstreams: toLines(d.upstreams),
}
}
/** `groups[2].match.cidrs` -> `cidrs`. */
function fieldLeaf(path: string): string {
const cleaned = path.replace(/^groups\[\d+\]\.?/, '').replace(/^groups\.\d+\.?/, '')
const parts = cleaned.split('.')
return parts[parts.length - 1] ?? cleaned
}
export default function Clients() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
const [defaultPolicy, setDefaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [fieldErrors, setFieldErrors] = useState<string[]>([])
const [pendingDelete, setPendingDelete] = useState<number | null>(null)
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
const save = useAsyncAction(async (payload: { groups: ClientGroup[]; default_policy: string }) => {
try {
return await putClients(payload)
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
try {
const parsed: unknown = JSON.parse(err.body)
if (parsed && typeof parsed === 'object') {
const fields = (parsed as { fields?: unknown }).fields
if (Array.isArray(fields)) {
setFieldErrors(fields.filter((f): f is string => typeof f === 'string'))
}
}
} catch {
/* a 400 without JSON is still reported through the toast */
}
}
throw err
}
})
const fieldError = (key: string): string | undefined => {
const leaf = fieldLeaf(key)
return fieldErrors.find((f) => fieldLeaf(f) === leaf)
}
const commit = async (
nextGroups: ClientGroup[],
nextDefault: string,
message: string,
): Promise<boolean> => {
const res = await save.run({ groups: nextGroups, default_policy: nextDefault })
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the client groups')
return false
}
notify(toast, 'ok', message)
refetch()
return true
}
const openNew = (): void => {
setEditingIndex(null)
setDraft(EMPTY_DRAFT)
setFieldErrors([])
setEditorOpen(true)
}
const openEdit = (index: number): void => {
const group = groups[index]
if (!group) return
setEditingIndex(index)
setDraft(fromGroup(group))
setFieldErrors([])
setEditorOpen(true)
}
const duplicate = (index: number): void => {
const group = groups[index]
if (!group) return
const copy = fromGroup(group)
setEditingIndex(null)
setDraft({ ...copy, name: `${copy.name}-copy` })
setFieldErrors([])
setEditorOpen(true)
}
const saveGroup = async (): Promise<void> => {
if (draft.name.trim() === '') {
setFieldErrors(['name'])
notify(toast, 'fail', 'a group needs a name')
return
}
const group = toGroup(draft)
const next = [...groups]
if (editingIndex !== null && next[editingIndex]) {
next[editingIndex] = group
} else {
next.push(group)
}
if (await commit(next, effectiveDefault, `saved ${group.name}`)) {
setEditorOpen(false)
setFieldErrors([])
}
}
const deleteGroup = async (): Promise<void> => {
const index = pendingDelete
if (index === null) return
const group = groups[index]
if (!group) return
const next = groups.filter((_, i) => i !== index)
if (await commit(next, effectiveDefault, `deleted ${group.name}`)) {
setPendingDelete(null)
}
}
const saveDefault = async (next: string): Promise<void> => {
setDefaultDraft(next)
const ok = await commit(groups, next, `default policy set to ${next}`)
if (!ok) {
setDefaultDraft(null)
return
}
setDefaultDraft(null)
}
if (error) return <PageError error={error} onRetry={refetch} />
return (
<div className="space-y-4">
<PageHeader
title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
actions={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add group
</Button>
}
/>
<Card title="Default policy" subtitle="Applied to clients that match no group.">
<div className="w-full sm:max-w-xs">
<Select
label="Policy"
value={effectiveDefault}
disabled={loading || save.pending}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
/>
</div>
</Card>
{groups.length === 0 ? (
<EmptyState
icon={<Users size={20} />}
title="No client groups"
description="Without a group every client gets the default policy."
action={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add the first group
</Button>
}
/>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{groups.map((group, i) => (
<Card key={group.name}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-medium">{group.name}</h3>
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
{group.policy}
</Badge>
</div>
{group.comment ? (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
) : null}
<p className="mt-1.5 truncate font-mono text-xs text-slate-500 dark:text-slate-400">
{matchSummary(group)}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
{group.log_queries ? <Badge tone="muted">log</Badge> : null}
{group.rate_limit > 0 ? (
<Badge tone="muted">{group.rate_limit} qps</Badge>
) : null}
{group.max_concurrent > 0 ? (
<Badge tone="muted">max {group.max_concurrent}</Badge>
) : null}
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${group.name}`}
variant="ghost"
onClick={() => setPendingDelete(i)}
/>
</div>
</div>
</Card>
))}
</div>
)}
<Dialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
description="Groups are matched top to bottom; the first match wins."
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
Cancel
</Button>
<Button onClick={() => void saveGroup()} loading={save.pending}>
{editingIndex === null ? 'Create group' : 'Save changes'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
error={fieldError('name')}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Policy"
value={draft.policy}
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
options={POLICIES}
/>
</div>
<Input
label="Comment"
value={draft.comment}
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Client CIDRs"
hint="One per line, e.g. 192.168.1.0/24"
monospace
rows={4}
value={draft.cidrs}
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
error={fieldError('cidrs')}
/>
<Textarea
label="Reverse-DNS suffixes"
hint="One per line, e.g. .lan"
monospace
rows={4}
value={draft.ptr_suffixes}
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
error={fieldError('ptr_suffixes')}
/>
</div>
<Textarea
label="Tags"
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
monospace
rows={2}
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
error={fieldError('tags')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Allow domains"
hint="Glob patterns that skip the blocklists for this group."
monospace
rows={3}
value={draft.allow_domains}
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
error={fieldError('allow_domains')}
/>
<Textarea
label="Deny domains"
hint="Glob patterns always refused for this group."
monospace
rows={3}
value={draft.deny_domains}
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
error={fieldError('deny_domains')}
/>
</div>
<Textarea
label="Upstreams"
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
monospace
rows={2}
value={draft.upstreams}
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
error={fieldError('upstreams')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Rate limit (qps)"
type="number"
min={0}
value={draft.rate_limit}
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
hint="0 disables the limit."
error={fieldError('rate_limit')}
/>
<Input
label="Max concurrent queries"
type="number"
min={0}
value={draft.max_concurrent}
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
hint="0 means unlimited."
error={fieldError('max_concurrent')}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Switch
checked={draft.any}
onChange={(next) => setDraft((d) => ({ ...d, any: next }))}
label="Match any client"
description="Last-resort group that matches everything not claimed above."
/>
<Switch
checked={draft.skip_blocklist}
onChange={(next) => setDraft((d) => ({ ...d, skip_blocklist: next }))}
label="Skip blocklists"
description="Resolve everything, even listed names."
/>
<Switch
checked={draft.skip_cache}
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
label="Skip cache"
description="Always go upstream, never serve a cached answer."
/>
<Switch
checked={draft.log_queries}
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
label="Log queries"
description="Include this group's queries in the query log."
/>
</div>
{fieldErrors.length > 0 ? (
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
<p className="font-medium">The server rejected these fields:</p>
<ul className="mt-1 list-inside list-disc font-mono text-xs">
{fieldErrors.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</div>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void deleteGroup()}
title="Delete this group?"
message={
pendingDelete !== null && groups[pendingDelete]
? `Clients matching "${groups[pendingDelete].name}" fall back to the default policy.`
: ''
}
confirmLabel="Delete"
destructive
pending={save.pending}
/>
</div>
)
}
+490
View File
@@ -0,0 +1,490 @@
/**
* Dashboard — the "is it working" page.
*
* Everything on it is a single `/api/dashboard` fetch for the selected range;
* the charts are pure functions of `series`, so they never need their own
* polling. The server builds that payload, so every field is read defensively:
* a node running an older build simply shows less, it never throws.
*/
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import {
Area,
Bar,
BarChart,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip as ChartTooltip,
XAxis,
YAxis,
} 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 {
formatClock,
formatCompact,
formatMs,
formatNumber,
formatPercent,
formatRate,
formatUptime,
} from '../../lib/format'
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
import { HealthPill, PageError, PageHeader } from '../components/layout'
/** Chart palette: fixed hex so it reads on both the light and dark theme. */
const GRID = 'rgba(148, 163, 184, 0.22)'
const AXIS_TEXT = '#94a3b8'
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> & {
degraded_reason?: string
reason?: string
message?: string
latency?: { p50_ms?: number; p95_ms?: number; p99_ms?: number }
recent?: LogItem[]
}
type Row = Record<string, ReactNode>
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
payload?: { name?: string; value?: number | string; color?: string }[]
}
/** Dark tooltip content, so the chart stays legible in the light theme too. */
function ChartTip({ active, payload, label }: TipProps) {
if (!active || !payload || 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">
<div className="mb-1 font-mono text-slate-400">{formatClock(Number(label) * 1000)}</div>
<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.color }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
function healthTone(health: string | undefined): 'default' | 'ok' | 'warn' | 'danger' {
if (health === 'ok') return 'ok'
if (health === 'degraded') return 'warn'
return 'danger'
}
function seriesOf(d: DashboardView) {
return d.series ?? []
}
export default function Dashboard() {
const { range, setRange } = useTimeRange('1h')
const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range])
useAutoRefresh(refetch, 5000)
const d = view(data)
const series = useMemo(() => seriesOf(d), [d])
const upstreams = useMemo(() => d.upstreams ?? [], [d])
const slowest = useMemo(
() => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0),
[upstreams],
)
const topBlocked = useMemo(() => d.top_blocked ?? [], [d])
const topQueried = useMemo(() => d.top_queried ?? [], [d])
const hitRate = d.cache_hit_rate ?? 0
const p99 = d.latency?.p99_ms
const p95Fallback = useMemo(() => {
const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0)
return values.length > 0 ? Math.max(...values) : undefined
}, [upstreams])
const latencyValue = p99 ?? p95Fallback
const latencyLabel = p99 != null ? 'p99 latency' : 'p95 latency'
const degraded = d.health != null && d.health !== 'ok'
const degradedReason =
d.degraded_reason ?? d.reason ?? d.message ?? 'no reason reported by the server'
if (error) return <PageError error={error} onRetry={refetch} />
const upstreamColumns = [
{ key: 'addr', header: 'Server', width: '28%' },
{ key: 'proto', header: 'Proto', width: '8%' },
{ key: 'health', header: 'Health', width: '12%' },
{ key: 'latency', header: 'Latency', width: '22%' },
{ key: 'fail', header: 'Fail rate', width: '12%', align: 'right' as const },
{ key: 'queries', header: 'Queries', width: '14%', align: 'right' as const },
]
const upstreamRows: Row[] = upstreams.map((u: UpstreamStat, i) => ({
__key: `${u.addr}-${i}`,
addr: <span className="font-mono text-xs">{u.addr}</span>,
proto: <Badge tone="muted">{u.proto}</Badge>,
health: <Badge tone={healthTone(u.health)}>{u.health}</Badge>,
latency: (
<span className="flex items-center gap-2">
<span className="w-12 shrink-0 font-mono text-xs text-slate-500 dark:text-slate-400">
{formatMs(u.latency_ms)}
</span>
<span className="w-full min-w-[60px]">
<Progress
value={u.latency_ms}
max={slowest || 1}
tone={u.fail_rate > 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'}
/>
</span>
</span>
),
fail: (
<span className="font-mono text-xs">{u.fail_rate > 0 ? formatRate(u.fail_rate) : '—'}</span>
),
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</span>,
}))
const recent: LogItem[] = d.recent ?? []
const recentSource: { name: string; list: string; action: string; ts?: string }[] =
recent.length > 0
? recent.slice(0, 10).map((e) => ({
name: e.name,
list: e.list,
action: e.action,
ts: e.ts,
}))
: topBlocked.slice(0, 10).map((e) => ({ name: e.domain, list: e.list, action: 'block' }))
return (
<div className="space-y-4">
<PageHeader
title="Dashboard"
description="Live traffic, cache efficiency and upstream health."
actions={
<div className="flex items-center gap-2">
<div
className="flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
role="group"
aria-label="Time range"
>
{TIME_RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={
range === r
? 'bg-slate-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-slate-100 dark:text-slate-900'
: 'px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}
>
{r}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
icon={<RefreshCw size={14} />}
onClick={refetch}
loading={loading}
>
Refresh
</Button>
</div>
}
/>
{degraded && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
>
<Activity size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<p className="font-semibold">The server reports degraded service</p>
<p className="mt-0.5 break-words opacity-90">{degradedReason}</p>
</div>
<Badge tone={healthTone(d.health)}>{d.health}</Badge>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Queries / s"
value={formatCompact(d.queries_per_sec ?? 0)}
hint={`${formatNumber(d.queries_total ?? 0)} total`}
icon={<Activity size={16} />}
/>
<StatCard
label="Cache hit rate"
value={formatRate(hitRate)}
hint="higher is better"
icon={<Gauge size={16} />}
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} />}
/>
<StatCard
label={latencyLabel}
value={latencyValue != null ? formatMs(latencyValue) : '—'}
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
icon={<Timer size={16} />}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds ?? 0)}
{d.node_id ? ` · node ${d.node_id}` : ''} · auto-refreshing every 5s
</p>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Queries" subtitle={`Last ${range} · total, cache hits and errors`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Area
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
fill={QUERIES}
fillOpacity={0.18}
strokeWidth={1.5}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="cache_hits"
name="cached"
stroke={CACHED}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="errors"
name="errors"
stroke={ERRORS}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card title="Blocked rate" subtitle={`Blocked queries per bucket, last ${range}`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Bar
dataKey="blocked"
name="blocked"
fill={BLOCKED}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Top blocked domains" subtitle="Ranked by hits in the selected range">
<CardBody>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={topBlocked}
layout="vertical"
margin={{ top: 0, right: 16, bottom: 0, left: 0 }}
>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
/>
<YAxis
type="category"
dataKey="domain"
width={180}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
tickFormatter={(value: string) =>
value.length > 26 ? `${value.slice(0, 25)}…` : value
}
/>
<ChartTooltip content={<ChartTip />} cursor={{ fill: 'rgba(148,163,184,0.12)' }} />
<Bar dataKey="count" name="blocked" fill={BLOCKED} radius={[0, 3, 3, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card
title="Recent blocks"
subtitle={recent.length > 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'}
>
<CardBody>
{recentSource.length === 0 ? (
<EmptyState
title="Nothing blocked yet"
description="No blocked queries in the selected range."
/>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{recentSource.map((e, i) => (
<li key={`${e.name}-${i}`} className="flex items-center gap-3 px-4 py-2">
<Badge tone="warn">{e.action || 'block'}</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{e.name}</span>
{e.list ? (
<span className="hidden truncate text-xs text-slate-500 sm:inline dark:text-slate-400">
{e.list}
</span>
) : null}
{e.ts ? (
<span className="font-mono text-xs text-slate-400">{formatClock(e.ts)}</span>
) : null}
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div className="xl:col-span-2">
<Card
title="Upstreams"
subtitle="Health, latency relative to the slowest server, and fail rate"
actions={<HealthPill health={d.health ?? 'down'} />}
>
<CardBody>
<div className="overflow-x-auto">
<Table
columns={upstreamColumns}
rows={upstreamRows}
rowKey={(row) => String(row.__key)}
empty="No upstreams are configured."
loading={loading && upstreams.length === 0}
/>
</div>
</CardBody>
</Card>
</div>
<Card title="Top queried" subtitle="Most requested names in range">
<CardBody>
{topQueried.length === 0 ? (
<EmptyState title="No query data" description="Nothing was resolved in this range." />
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{topQueried.slice(0, 15).map((q) => (
<li key={`${q.domain}-${q.qtype}`} className="flex items-center gap-2 px-4 py-2 text-sm">
<Badge tone={q.blocked ? 'warn' : 'default'}>
{q.qtype || '?'}
</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{q.domain}</span>
<span className="font-mono text-xs text-slate-500">{formatNumber(q.count)}</span>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
)
}
+378
View File
@@ -0,0 +1,378 @@
/**
* Domains — the inline rules that override the downloaded lists.
*
* The table is server-paged, so every filter change rewinds to page 1 and the
* query object is memoised so `useApi` only refetches when something the
* server cares about actually changed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Plus, Search, Trash2 } from 'lucide-react'
import { addDomain, deleteDomain, getDomains } from '../../lib/api'
import type { AddDomainInput } from '../../lib/api'
import type { DomainItem } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { useDebounced } from '../../hooks/useDebounced'
import { formatNumber, formatRelative, toLines } from '../../lib/format'
import {
Badge,
Button,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Pagination,
Select,
Table,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const PER_PAGE = 50
const ACTIONS = [
{ value: 'block', label: 'Block (NXDOMAIN)' },
{ value: 'allow', label: 'Allow (pass through)' },
{ value: 'drop', label: 'Drop (no response)' },
{ value: 'nxdomain', label: 'NXDOMAIN' },
{ value: 'redirect', label: 'Redirect' },
]
const NAME_HINT =
'*.example.com matches subdomains, **.example.com matches the apex too, a bare name matches exactly.'
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
switch (action) {
case 'allow':
return 'ok'
case 'block':
return 'warn'
case 'drop':
return 'danger'
case 'nxdomain':
return 'warn'
case 'redirect':
return 'info'
default:
return 'default'
}
}
type AddForm = {
name: string
action: string
list: string
bulk: string
advanced: boolean
}
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
export default function Domains() {
const toast = useToast()
const [search, setSearch] = useState('')
const [action, setAction] = useState('')
const [list, setList] = useState('')
const [page, setPage] = useState(1)
const q = useDebounced(search, 300)
const [addOpen, setAddOpen] = useState(false)
const [form, setForm] = useState<AddForm>(EMPTY_FORM)
const [formError, setFormError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<DomainItem | null>(null)
// A new filter makes the old page number meaningless.
useEffect(() => {
setPage(1)
}, [q, action, list])
const filters = useMemo(
() => ({
q: q.trim(),
action: action === '' ? undefined : action,
list: list === '' ? undefined : list,
page,
per_page: PER_PAGE,
}),
[q, action, list, page],
)
const { data, error, loading, refetch } = useApi(() => getDomains(filters), [filters])
const items: DomainItem[] = useMemo(() => data?.items ?? [], [data])
const total = data?.total ?? items.length
const add = useAsyncAction(async (input: AddDomainInput) => addDomain(input))
const remove = useAsyncAction(async (name: string, act: string, src: string) =>
deleteDomain(name, act, src),
)
const lists = useMemo(() => {
const seen = new Set<string>()
for (const item of items) {
if (item.list) seen.add(item.list)
if (item.source) seen.add(item.source)
}
return Array.from(seen).sort()
}, [items])
const bulkLines = useMemo(() => toLines(form.bulk), [form.bulk])
const canSubmit =
form.action !== '' && (form.bulk.trim() !== '' || form.name.trim() !== '')
const submit = async (): Promise<void> => {
setFormError(null)
if (!canSubmit) {
setFormError('enter a domain, or paste rules in the bulk box')
return
}
if (form.bulk.trim() !== '') {
for (const line of bulkLines) {
const allow = line.startsWith('!')
const ruleName = allow ? line.slice(1).trim() : line
if (ruleName === '') continue
const res = await add.run({
name: ruleName,
action: allow ? 'allow' : form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
notify(toast, 'fail', add.error ?? `could not add ${ruleName}`)
return
}
}
notify(toast, 'ok', `added ${bulkLines.length} rule${bulkLines.length === 1 ? '' : 's'}`)
} else {
const res = await add.run({
name: form.name.trim(),
action: form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
setFormError(add.error ?? 'the server rejected that rule')
notify(toast, 'fail', add.error ?? 'could not add the rule')
return
}
notify(toast, 'ok', `added ${form.name.trim()}`)
}
setForm(EMPTY_FORM)
setAddOpen(false)
refetch()
}
const confirmDelete = async (): Promise<void> => {
const target = pendingDelete
if (!target) return
const res = await remove.run(target.name, String(target.action), target.list || target.source)
if (res === null) {
notify(toast, 'fail', remove.error ?? `could not delete ${target.name}`)
return
}
setPendingDelete(null)
notify(toast, 'ok', `deleted ${target.name}`)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'Domain', width: '40%' },
{ key: 'action', header: 'Action', width: '16%' },
{ key: 'source', header: 'List', width: '20%' },
{ key: 'hits', header: 'Hits', width: '10%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '10%' },
{ key: 'delete', header: '', width: '4%', align: 'right' as const },
]
const rows: Row[] = items.map((item, i) => ({
__key: `${item.name}-${item.action}-${item.list}-${i}`,
name: <span className="block max-w-[320px] truncate font-mono text-xs">{item.name}</span>,
action: <Badge tone={actionTone(String(item.action))}>{item.action}</Badge>,
source: (
<span className="block max-w-[200px] truncate text-xs text-slate-500 dark:text-slate-400">
{item.list || item.source || '—'}
</span>
),
hits: <span className="font-mono text-xs">{formatNumber(item.hits)}</span>,
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
delete: (
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${item.name}`}
variant="ghost"
onClick={() => setPendingDelete(item)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Domains"
description="Inline allow and block rules, checked before the downloaded lists."
actions={
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}>
Add rule
</Button>
}
/>
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input
label="Search"
placeholder="ads.example.com"
icon={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Action"
value={action}
onChange={(e) => setAction(e.target.value)}
options={[{ value: '', label: 'All actions' }, ...ACTIONS.map((a) => ({ value: a.value, label: a.label }))]}
/>
<Select
label="List"
value={list}
onChange={(e) => setList(e.target.value)}
options={[{ value: '', label: 'All lists' }, ...lists.map((l) => ({ value: l, label: l }))]}
/>
</div>
</Card>
<Card
title="Rules"
subtitle={`${formatNumber(total)} matching`}
padded={false}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading}
empty={
<EmptyState
title="No rules match"
description="Change the filters, or add a rule with the button above."
/>
}
/>
</div>
<div className="border-t border-slate-200 p-3 dark:border-slate-800">
<Pagination page={page} perPage={PER_PAGE} total={total} onPage={setPage} />
</div>
</Card>
<Dialog
open={addOpen}
onClose={() => setAddOpen(false)}
title="Add a rule"
description="Rules are checked before the blocklists, so an allow rule wins over a block."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setAddOpen(false)} disabled={add.pending}>
Cancel
</Button>
<Button onClick={() => void submit()} loading={add.pending} disabled={!canSubmit}>
{bulkLines.length > 0 ? `Add ${bulkLines.length} rules` : 'Add rule'}
</Button>
</div>
}
>
<div className="space-y-3">
{formError ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{formError}
</p>
) : null}
<Input
label="Domain"
placeholder="ads.example.com"
hint={NAME_HINT}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={form.advanced}
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Action"
value={form.action}
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
/>
<Input
label="List"
placeholder="inline"
hint="Optional group name, defaults to the inline list."
value={form.list}
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
autoComplete="off"
/>
</div>
<button
type="button"
className="text-xs font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400"
onClick={() => setForm((f) => ({ ...f, advanced: !f.advanced }))}
>
{form.advanced ? 'Hide bulk paste' : 'Bulk paste'}
</button>
{form.advanced ? (
<Textarea
label="Rules"
hint={`One per line; a leading ! makes it an allow rule. ${bulkLines.length} line${bulkLines.length === 1 ? '' : 's'} parsed.`}
monospace
rows={8}
value={form.bulk}
onChange={(e) => setForm((f) => ({ ...f, bulk: e.target.value }))}
spellCheck={false}
/>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void confirmDelete()}
title="Delete this rule?"
message={
pendingDelete
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
: ''
}
confirmLabel="Delete"
destructive
pending={remove.pending}
/>
</div>
)
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more