diff --git a/.codesearch/codesearch.db b/.codesearch/codesearch.db new file mode 100644 index 0000000..8ac4f19 Binary files /dev/null and b/.codesearch/codesearch.db differ diff --git a/.codesearch/session.json b/.codesearch/session.json new file mode 100644 index 0000000..269ac30 --- /dev/null +++ b/.codesearch/session.json @@ -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" +} \ No newline at end of file diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..59ce35b --- /dev/null +++ b/STATUS.md @@ -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). diff --git a/cmd/fancy-dns/main.go b/cmd/fancy-dns/main.go new file mode 100644 index 0000000..822a557 --- /dev/null +++ b/cmd/fancy-dns/main.go @@ -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 +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml new file mode 100644 index 0000000..ad1ff35 --- /dev/null +++ b/deploy/config.example.yaml @@ -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 diff --git a/deploy/local.example.zone b/deploy/local.example.zone new file mode 100644 index 0000000..e014a63 --- /dev/null +++ b/deploy/local.example.zone @@ -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" diff --git a/deploy/zones/local.rpz.zone.example b/deploy/zones/local.rpz.zone.example new file mode 100644 index 0000000..1c25690 --- /dev/null +++ b/deploy/zones/local.rpz.zone.example @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c83f328 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a2deba2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/acl/acl.go b/internal/acl/acl.go new file mode 100644 index 0000000..0ec3058 --- /dev/null +++ b/internal/acl/acl.go @@ -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] +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..71bf230 --- /dev/null +++ b/internal/api/api.go @@ -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 +} diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..61ba680 --- /dev/null +++ b/internal/api/auth.go @@ -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, + }) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..ebeebea --- /dev/null +++ b/internal/api/handlers.go @@ -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 +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..1c31ebb --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/app/harness_test.go b/internal/app/harness_test.go new file mode 100644 index 0000000..85a6441 --- /dev/null +++ b/internal/app/harness_test.go @@ -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 diff --git a/internal/app/hooks.go b/internal/app/hooks.go new file mode 100644 index 0000000..a375c8d --- /dev/null +++ b/internal/app/hooks.go @@ -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}, +} diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go new file mode 100644 index 0000000..8ac6848 --- /dev/null +++ b/internal/app/integration_test.go @@ -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) + } + } +} diff --git a/internal/app/testutil_test.go b/internal/app/testutil_test.go new file mode 100644 index 0000000..cbaec02 --- /dev/null +++ b/internal/app/testutil_test.go @@ -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) + } +} diff --git a/internal/blocklist/manager.go b/internal/blocklist/manager.go new file mode 100644 index 0000000..c4fea9d --- /dev/null +++ b/internal/blocklist/manager.go @@ -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} } diff --git a/internal/blocklist/parse.go b/internal/blocklist/parse.go new file mode 100644 index 0000000..a68bf04 --- /dev/null +++ b/internal/blocklist/parse.go @@ -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, ".") +} diff --git a/internal/blocklist/set.go b/internal/blocklist/set.go new file mode 100644 index 0000000..24b8341 --- /dev/null +++ b/internal/blocklist/set.go @@ -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) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..25372b5 --- /dev/null +++ b/internal/cache/cache.go @@ -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 +} diff --git a/internal/cache/lru.go b/internal/cache/lru.go new file mode 100644 index 0000000..bf3b5e3 --- /dev/null +++ b/internal/cache/lru.go @@ -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) +} diff --git a/internal/cache/manager.go b/internal/cache/manager.go new file mode 100644 index 0000000..27a629b --- /dev/null +++ b/internal/cache/manager.go @@ -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 +} diff --git a/internal/cache/store.go b/internal/cache/store.go new file mode 100644 index 0000000..b4adabf --- /dev/null +++ b/internal/cache/store.go @@ -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() +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go new file mode 100644 index 0000000..8822346 --- /dev/null +++ b/internal/cluster/cluster.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..06948ba --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,1098 @@ +// Package config holds the declarative configuration for fancy-dns, its +// validation, and the atomic hot-reloadable store the whole server reads from. +// +// Configuration is intentionally file-based (YAML for the service itself, +// DNS master files for zone data) so that a deployment can be reviewed, +// diffed and rolled back with git. Mutations made through the admin API are +// written back to the same file and then broadcast to the cluster. +package config + +import ( + "errors" + "fmt" + "net" + "net/netip" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration is a time.Duration that unmarshals from a YAML/JSON string such as +// "30s" or "1h30m". A bare integer is interpreted as seconds. +type Duration time.Duration + +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err == nil { + s = strings.TrimSpace(s) + if s == "" { + *d = 0 + return nil + } + // Bare numbers are seconds, matching the convention used by the + // surrounding YAML (and by CoreDNS-ish configs people copy around). + if n, err := time.ParseDuration(s); err == nil { + *d = Duration(n) + return nil + } + return fmt.Errorf("invalid duration %q", s) + } + var f float64 + if err := value.Decode(&f); err != nil { + return fmt.Errorf("invalid duration: %s", value.Value) + } + *d = Duration(time.Duration(f * float64(time.Second))) + return nil +} + +func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil } + +func (d Duration) D() time.Duration { return time.Duration(d) } + +// Config is the root document. Every section has sane defaults applied by +// Default() so that a minimal config file is still a working server. +type Config struct { + // Version is bumped by the admin API when it rewrites the file; it is + // echoed in /api/config and used to detect divergence in the cluster. + Version int64 `yaml:"version,omitempty"` + + Server Server `yaml:"server"` + DNS DNS `yaml:"dns"` + Upstream Upstream `yaml:"upstream"` + Cache Cache `yaml:"cache"` + Block Block `yaml:"blocklist"` + ACL ACL `yaml:"acl"` + QueryLog QueryLog `yaml:"qlog"` + Cluster Cluster `yaml:"cluster"` + API API `yaml:"api"` + Metrics Metrics `yaml:"metrics"` + Log Log `yaml:"log"` +} + +// Server groups the process-level knobs. +type Server struct { + // MaxConcurrent caps in-flight upstream resolutions; excess queries are + // answered with SERVFAIL rather than queueing without bound. + MaxConcurrent int `yaml:"max_concurrent"` + // MaxConcurrentPerClient mirrors MaxConcurrent but per client IP. 0 = off. + MaxConcurrentPerClient int `yaml:"max_concurrent_per_client"` + Timeouts Timeout `yaml:"timeouts"` + // WorkingDir is only informational (shown in the UI). + WorkingDir string `yaml:"working_dir,omitempty"` +} + +type Timeout struct { + UDP Duration `yaml:"udp"` + TCP Duration `yaml:"tcp"` + TLS Duration `yaml:"tls"` + Query Duration `yaml:"query"` + Upstre Duration `yaml:"upstream"` + List Duration `yaml:"list_fetch"` +} + +// DNS describes every listener the server exposes. +type DNS struct { + UDP []string `yaml:"udp"` + TCP []string `yaml:"tcp"` + DoT DoT `yaml:"dot"` + DoH DoH `yaml:"doh"` + EDNS EDNS `yaml:"edns0"` + // ServeStale answers from cache after an upstream failure. + ServeStale Duration `yaml:"serve_stale"` + // MaxUDPSize is what we advertise as our own EDNS0 buffer size. + MaxUDPSize uint16 `yaml:"max_udp_size"` + // Truncate policy: "question" sets TC and returns the header+question. + Recursion bool `yaml:"recursion_desired"` + // HideIdentity stops leaking our version string in CHAOS/bind. + HideIdentity bool `yaml:"hide_identity"` +} + +type DoT struct { + Enabled bool `yaml:"enabled"` + Listen []string `yaml:"listen"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + // ClientCA enables mTLS when set; clients must then present a cert. + ClientCA string `yaml:"client_ca"` + // MinVersion is "1.2" or "1.3". + MinVersion string `yaml:"min_version"` + // Upstream makes our own outgoing queries encrypted (RFC 7858, 8.4.1). + Upstream bool `yaml:"upstream"` +} + +type DoH struct { + Enabled bool `yaml:"enabled"` + Listen []string `yaml:"listen"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + BasePath string `yaml:"base_path"` + // EchConfig is an optional ECHConfigList to serve over HTTPS (RFC 9460). + MinVersion string `yaml:"min_version"` + // AllowPost enables the RFC 8484 POST profile in addition to GET. + AllowPost bool `yaml:"allow_post"` + Transport Transport `yaml:"transport"` +} + +// Transport selects between HTTP/2 (h2) and HTTP/3 (h3) for DoH. +type Transport struct { + // "auto" upgrades to HTTP/3 when the listener is UDP, else h2. + Mode string `yaml:"mode"` + // AltSvc lets the listener advertise h3 via Alt-Svc. + AltSvc string `yaml:"alt_svc"` +} + +type EDNS struct { + // ClientSubnet enables RFC 7871 EDNS-Client-Subnet passthrough. + ClientSubnet bool `yaml:"client_subnet"` + IPv4Bits uint8 `yaml:"ipv4_bits"` + IPv6Bits uint8 `yaml:"ipv6_bits"` + // MaxUDPSize overrides the advertised EDNS0 buffer for our own answers. + CookieSupport bool `yaml:"cookie_support"` + // PreserveEDNS0 copies the incoming OPT options into upstream queries. + PreserveEDNS0 bool `yaml:"preserve"` +} + +type Upstream struct { + // Servers is the upstream pool. + Servers []UpstreamServer `yaml:"servers"` + // Policy is "parallel" (query all, first answer wins), "random" (pick N) + // or "failover" (walk the list in order). + Policy string `yaml:"policy"` + // MaxAttempts bounds how many upstreams are tried per query. + MaxAttempts int `yaml:"max_attempts"` + // Healthcheck interval; 0 disables active health probing. + HealthCheck Duration `yaml:"healthcheck_interval"` + // CircuitBreaker trips an upstream after this many consecutive failures. + BreakerThreshold int `yaml:"breaker_threshold"` + BreakerCooldown Duration `yaml:"breaker_cooldown"` + // Bootstraps resolves upstream hostnames at startup and on change. + Bootstraps []UpstreamBootstrap `yaml:"bootstraps"` +} + +type UpstreamServer struct { + // Addr is host:port; host may be an IP or a name resolved via Bootstraps. + Addr string `yaml:"addr"` + // Proto is "udp", "tcp" or "tls" (DoT, 8.4.1). + Proto string `yaml:"proto"` + // ServerName / InsecureSkipVerify are only used with proto: tls. + ServerName string `yaml:"server_name"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` + // HealthPath is a hostname used for health probes instead of the Addr host. + HealthName string `yaml:"health_name"` + // Weight biases random selection (default 1). + Weight int `yaml:"weight"` + // MaxFails before the breaker opens; 0 uses the global threshold. + MaxFails int `yaml:"max_fails"` + Comment string `yaml:"comment,omitempty"` +} + +type UpstreamBootstrap struct { + Name string `yaml:"name"` + Addresses []string `yaml:"addresses"` + // Refresh re-resolves periodically (for dyn names). + Refresh Duration `yaml:"refresh"` +} + +type Cache struct { + Enabled bool `yaml:"enabled"` + // Size is the in-memory entry budget for the LRU. + Size int `yaml:"size"` + // Shards must be a power of two; 0 picks 32. + Shards int `yaml:"shards"` + // MinTTL/MaxTTL clamp the TTLs taken from upstream answers. + MinTTL Duration `yaml:"min_ttl"` + MaxTTL Duration `yaml:"max_ttl"` + // ServeStale keeps expired entries around for this long so a dead + // upstream does not turn into a latency cliff. + ServeStale Duration `yaml:"serve_stale"` + // Prefetch keeps ServeStale windows warm. + Prefetch bool `yaml:"prefetch"` + // Persist writes a snapshot to bbolt so restarts stay warm. + Persist CachePersist `yaml:"persist"` + // Prewarm is a list of "domain/type" (e.g. "example.com/A") to load at boot. + Prewarm []string `yaml:"prewarm"` + // Negative caches NXDOMAIN/NODATA, bounded by the SOA minimum. + NegativeTTL Duration `yaml:"negative_ttl"` +} + +type CachePersist struct { + Enabled bool `yaml:"enabled"` + // Path is the bbolt file; defaults under the data directory. + Path string `yaml:"path"` + // Interval is the snapshot write cadence. + Interval Duration `yaml:"interval"` + // MaxEntries caps the persisted snapshot (0 = no cap). + MaxEntries int `yaml:"max_entries"` + // LoadOnStart reads a snapshot from disk at boot. + LoadOnStart bool `yaml:"load_on_start"` +} + +type Block struct { + Enabled bool `yaml:"enabled"` + // DefaultAction is "nxdomain", "zero", "redirect" or "drop". + DefaultAction string `yaml:"default_action"` + // ResponseIP/ResponseIPv6 are used by the "zero" action. + ResponseIP string `yaml:"response_ip"` + ResponseIPv6 string `yaml:"response_ipv6"` + ResponseTTL Duration `yaml:"response_ttl"` + // ResponseName/CNAME are used by the "redirect" action. + RedirectName string `yaml:"redirect_name"` + // LocalZones are DNS master files (RFC 1035) holding RPZ-style policy + // records and/or locally defined zones. + LocalZones []string `yaml:"local_zones"` + // Lists are remote or on-disk sources of block data. + Lists []ListSource `yaml:"lists"` + // AllowEntries and BlockEntries are inline rules (a list of DNS names or + // master-file lines), useful for small static policies. + AllowEntries []string `yaml:"allow_entries"` + BlockEntries []string `yaml:"block_entries"` + // Parallelism for list parsing/compilation. + Parallelism int `yaml:"parallelism"` +} + +type ListSource struct { + Name string `yaml:"name"` + // Format is auto|hosts|adblock|plain|rpz. + Format string `yaml:"format"` + // URL and Path: at least one is required. URL wins when both are set; the + // downloaded copy is written to Path so restarts work without network. + URL string `yaml:"url"` + Path string `yaml:"path"` + Mirror []string `yaml:"mirror"` + Enabled bool `yaml:"enabled"` + // Refresh interval; 0 disables scheduled refresh (manual only). + Refresh Duration `yaml:"refresh"` + // Timeout for the fetch. + Timeout Duration `yaml:"timeout"` + // MaxBytes guards against a runaway/hostile list. + MaxBytes int64 `yaml:"max_bytes"` + // ETag / LastModified enable conditional refreshes. + ETag string `yaml:"etag"` + LastModified string `yaml:"last_modified"` + // Category tags feed the UI breakdown. + Categories []string `yaml:"categories"` + // InsecureSkipTLSVerify is only for self-hosted mirrors. + InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"` + // LastGood keeps the previous compiled set if a refresh fails. + Comment string `yaml:"comment,omitempty"` +} + +type ACL struct { + // DefaultPolicy is "allow", "deny" or "block_only". + DefaultPolicy string `yaml:"default_policy"` + // DefaultRateLimit is queries/second per client IP; 0 = unlimited. + DefaultRateLimit int `yaml:"default_rate_limit"` + Groups []Group `yaml:"groups"` +} + +// Group is a named set of clients sharing a policy. Groups are evaluated +// top-to-bottom; the first match wins. +type Group struct { + Name string `yaml:"name"` + Comment string `yaml:"comment,omitempty"` + Match Match `yaml:"match"` + // Policy is "allow" (full resolution), "block_only" (blocklists still + // apply, everything else resolves) or "deny" (REFUSED). + Policy string `yaml:"policy"` + // AllowDomains / DenyDomains are per-group overrides; the names support + // the same wildcards as blocklists ("*.example.com", "**.example.com"). + AllowDomains []string `yaml:"allow_domains"` + DenyDomains []string `yaml:"deny_domains"` + // BlocklistToggle lets a group opt out of blocklist filtering. + SkipBlocklist bool `yaml:"skip_blocklist"` + // CacheToggle lets a group bypass the cache (fresh lookups only). + SkipCache bool `yaml:"skip_cache"` + // RateLimit overrides ACL.DefaultRateLimit for this group. + RateLimit int `yaml:"rate_limit"` + // MaxConcurrent overrides Server.MaxConcurrentPerClient. + MaxConcurrent int `yaml:"max_concurrent"` + // LogQueries forces query logging for this group regardless of sampling. + LogQueries bool `yaml:"log_queries"` + Upstreams []string `yaml:"upstreams"` +} + +// Match selects clients by network identity. +type Match struct { + // CIDRs are matched against the client address (v4 or v6). + CIDRs []string `yaml:"cidrs"` + // Networks match DNS clients identified by a resolved PTR of their + // reverse name; the name must end with one of the suffixes. + PTRSuffixes []string `yaml:"ptr_suffixes"` + // Tags are matched against the client tag set derived from PTR labels, + // e.g. "laptop" for "laptop.lan.internal.". + Tags []string `yaml:"tags"` + // Always matches (useful as a catch-all fallback group). + Any bool `yaml:"any"` +} + +type QueryLog struct { + Enabled bool `yaml:"enabled"` + // Path is the bbolt file backing the query log ring. + Path string `yaml:"path"` + // MaxEntries bounds the ring; oldest are dropped. + MaxEntries int `yaml:"max_entries"` + // FlushInterval batches writes. + FlushInterval Duration `yaml:"flush_interval"` + // SampleRate is 0..1 for non-blocked, non-error queries. + SampleRate float64 `yaml:"sample_rate"` + // What to record. + LogBlocked bool `yaml:"log_blocked"` + LogCached bool `yaml:"log_cached"` + LogErrors bool `yaml:"log_errors"` + LogClients bool `yaml:"log_client_names"` + // LiveBuffer is the ring of recent entries kept in memory for the UI/SSE. + LiveBuffer int `yaml:"live_buffer"` + // Retention of aggregated per-domain counters. + TopDomains int `yaml:"top_domains"` + TopTTL Duration `yaml:"top_ttl"` +} + +type Cluster struct { + Enabled bool `yaml:"enabled"` + // NodeID defaults to hostname; instances use it to elect a refresh leader. + NodeID string `yaml:"node_id"` + // Advertise is the address other instances dial for the admin API. + Advertise string `yaml:"advertise"` + // PeerSync makes this instance push its config file to peers. + PeerSync bool `yaml:"peer_sync"` + PeerTTL Duration `yaml:"peer_ttl"` + Valkey Valkey `yaml:"valkey"` +} + +type Valkey struct { + // Addrs is a list of host:port. + Addrs []string `yaml:"addrs"` + // Username/Password for ACL-based auth. + Username string `yaml:"username"` + Password string `yaml:"password"` + DB int `yaml:"db"` + Prefix string `yaml:"prefix"` + TLS bool `yaml:"tls"` + PoolSize int `yaml:"pool_size"` + // DialTimeout/ReadTimeout bound cluster operations. + DialTimeout Duration `yaml:"dial_timeout"` + ReadTimeout Duration `yaml:"read_timeout"` + // LeaderTTL is the lease length for list-refresh leadership. + LeaderTTL Duration `yaml:"leader_ttl"` +} + +type API struct { + Enabled bool `yaml:"enabled"` + // Listen is the plain HTTP admin/API listener. + Listen []string `yaml:"listen"` + // TLSListen serves the admin API (and UI) over TLS instead. + TLSListen []string `yaml:"tls_listen"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + BasePath string `yaml:"base_path"` + Auth Auth `yaml:"auth"` + RateLimit AuthRate `yaml:"rate_limit"` + // UI toggles the embedded admin console. + UI bool `yaml:"ui"` + // AllowConfigWrite lets the API rewrite the config file. + AllowConfigWrite bool `yaml:"allow_config_write"` + // AllowBlocklistWrite lets the API write blocklist files to disk. + AllowBlocklistWrite bool `yaml:"allow_blocklist_write"` + // TrustedProxies is the set of proxies allowed to set X-Forwarded-For. + TrustedProxies []string `yaml:"trusted_proxies"` + // CORSOrigins; "*" is allowed but discouraged. + CORSOrigins []string `yaml:"cors_origins"` +} + +type Auth struct { + // Mode is "none", "apikey" or "basic". + Mode string `yaml:"mode"` + // APIKeys grant full access. + APIKeys []string `yaml:"api_keys"` + // ReadOnlyAPIKeys may only call GET endpoints. + ReadOnlyAPIKeys []string `yaml:"read_only_api_keys"` + // Basic user; Password may be plaintext (bcrypt-hashed at load time) or a + // bcrypt hash (prefix "$2"). + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionTTL Duration `yaml:"session_ttl"` + // TrustLoopback skips auth for requests from 127.0.0.0/8 and ::1. + TrustLoopback bool `yaml:"trust_loopback"` +} + +type AuthRate struct { + // Requests per minute per IP for the login endpoint. + PerMinute int `yaml:"per_minute"` + // Failures per minute per IP before a temporary lockout. + Failures int `yaml:"failures"` + Lockout Duration `yaml:"lockout"` +} + +type Metrics struct { + Enabled bool `yaml:"enabled"` + Path string `yaml:"path"` + // Protect requires the API token for /metrics. + Protect bool `yaml:"protect"` +} + +type Log struct { + // Level is debug|info|warn|error. + Level string `yaml:"level"` + // Format is text|json. + Format string `yaml:"format"` + // Addr overrides the log destination; empty means stderr. + Addr string `yaml:"addr"` +} + +// Default returns a fully-populated configuration. Callers are expected to +// unmarshal the user file on top of it, then Validate. +func Default() *Config { + return &Config{ + Server: Server{ + MaxConcurrent: 1024, + MaxConcurrentPerClient: 0, + Timeouts: Timeout{ + UDP: Duration(2 * time.Second), + TCP: Duration(4 * time.Second), + TLS: Duration(4 * time.Second), + Query: Duration(5 * time.Second), + Upstre: Duration(2 * time.Second), + List: Duration(60 * time.Second), + }, + }, + DNS: DNS{ + UDP: []string{"0.0.0.0:53", "[::]:53"}, + TCP: []string{"0.0.0.0:53", "[::]:53"}, + MaxUDPSize: 1232, + Recursion: true, + DoT: DoT{MinVersion: "1.2", Listen: []string{"0.0.0.0:853"}}, + DoH: DoH{BasePath: "/dns-query", MinVersion: "1.2", AllowPost: true, Transport: Transport{Mode: "auto"}, Listen: []string{"0.0.0.0:443"}}, + EDNS: EDNS{ClientSubnet: true, IPv4Bits: 24, IPv6Bits: 56, PreserveEDNS0: true}, + ServeStale: Duration(0), + }, + Upstream: Upstream{ + Policy: "parallel", + Servers: []UpstreamServer{ + {Addr: "9.9.9.9:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"}, + {Addr: "149.112.112.112:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"}, + {Addr: "1.1.1.1:53", Proto: "tls", ServerName: "cloudflare-dns.com", Weight: 1, HealthName: "cloudflare.com"}, + {Addr: "9.9.9.9:853", Proto: "tls", ServerName: "dns.quad9.net", Weight: 1, HealthName: "dns.quad9.net"}, + {Addr: "1.0.0.1:53", Proto: "udp", Weight: 1, HealthName: "one.one.one.one"}, + }, + MaxAttempts: 3, + HealthCheck: Duration(30 * time.Second), + BreakerThreshold: 5, + BreakerCooldown: Duration(30 * time.Second), + }, + Cache: Cache{ + Enabled: true, + Size: 200000, + Shards: 32, + MinTTL: Duration(0), + MaxTTL: Duration(time.Hour), + ServeStale: Duration(30 * time.Minute), + Prefetch: true, + NegativeTTL: Duration(30 * time.Minute), + Persist: CachePersist{ + Enabled: true, + Interval: Duration(30 * time.Second), + MaxEntries: 500000, + LoadOnStart: true, + }, + }, + Block: Block{ + Enabled: true, + DefaultAction: "nxdomain", + ResponseIP: "0.0.0.0", + ResponseIPv6: "::", + ResponseTTL: Duration(60 * time.Second), + Parallelism: 4, + }, + ACL: ACL{ + DefaultPolicy: "allow", + }, + QueryLog: QueryLog{ + Enabled: true, + MaxEntries: 200000, + FlushInterval: Duration(time.Second), + SampleRate: 1, + LogBlocked: true, + LogCached: true, + LogErrors: true, + LiveBuffer: 2000, + TopDomains: 5000, + TopTTL: Duration(5 * time.Minute), + }, + Cluster: Cluster{ + Enabled: false, + PeerTTL: Duration(15 * time.Second), + Valkey: Valkey{ + Prefix: "fancydns", + PoolSize: 10, + DialTimeout: Duration(2 * time.Second), + ReadTimeout: Duration(2 * time.Second), + LeaderTTL: Duration(30 * time.Second), + }, + }, + API: API{ + Enabled: true, + Listen: []string{"127.0.0.1:8080"}, + BasePath: "/api", + UI: true, + AllowConfigWrite: true, + AllowBlocklistWrite: true, + Auth: Auth{ + Mode: "apikey", + SessionTTL: Duration(12 * time.Hour), + TrustLoopback: true, + }, + RateLimit: AuthRate{PerMinute: 60, Failures: 10, Lockout: Duration(5 * time.Minute)}, + }, + Metrics: Metrics{Enabled: true, Path: "/metrics"}, + Log: Log{Level: "info", Format: "text"}, + } +} + +// Load reads path (if non-empty) on top of Default and validates the result. +func Load(path string) (*Config, error) { + cfg := Default() + if path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + if err := yaml.Unmarshal(raw, cfg); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + cfg.applyDefaults() + cfg.WorkingDirOrSet(filepath.Dir(path)) + } else { + cfg.WorkingDirOrSet(".") + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// WorkingDirOrSet records the config directory so relative paths in the +// document resolve consistently regardless of the process cwd. +func (c *Config) WorkingDirOrSet(dir string) { + if c.Server.WorkingDir == "" { + c.Server.WorkingDir = dir + } +} + +// applyDefaults backfills values that a hand-written file may omit, and +// normalises "off"/"disabled"/"" style booleans. +func (c *Config) applyDefaults() { + d := Default() + if c.Server.MaxConcurrent <= 0 { + c.Server.MaxConcurrent = d.Server.MaxConcurrent + } + if c.Server.Timeouts.UDP <= 0 { + c.Server.Timeouts.UDP = d.Server.Timeouts.UDP + } + if c.Server.Timeouts.TCP <= 0 { + c.Server.Timeouts.TCP = d.Server.Timeouts.TCP + } + if c.Server.Timeouts.TLS <= 0 { + c.Server.Timeouts.TLS = d.Server.Timeouts.TLS + } + if c.Server.Timeouts.Query <= 0 { + c.Server.Timeouts.Query = d.Server.Timeouts.Query + } + if c.Server.Timeouts.Upstre <= 0 { + c.Server.Timeouts.Upstre = d.Server.Timeouts.Upstre + } + if c.Server.Timeouts.List <= 0 { + c.Server.Timeouts.List = d.Server.Timeouts.List + } + if c.DNS.MaxUDPSize < 512 { + c.DNS.MaxUDPSize = d.DNS.MaxUDPSize + } + if c.DNS.DoH.BasePath == "" { + c.DNS.DoH.BasePath = d.DNS.DoH.BasePath + } + if c.DNS.DoT.MinVersion == "" { + c.DNS.DoT.MinVersion = d.DNS.DoT.MinVersion + } + if c.DNS.DoH.MinVersion == "" { + c.DNS.DoH.MinVersion = d.DNS.DoH.MinVersion + } + if c.DNS.DoH.Transport.Mode == "" { + c.DNS.DoH.Transport.Mode = "auto" + } + if c.EDNSBits() { + // nothing; kept for symmetry + } + if c.EDNS().IPv4Bits == 0 { + c.DNS.EDNS.IPv4Bits = d.DNS.EDNS.IPv4Bits + } + if c.DNS.EDNS.IPv6Bits == 0 { + c.DNS.EDNS.IPv6Bits = d.DNS.EDNS.IPv6Bits + } + if c.Upstream.Policy == "" { + c.Upstream.Policy = d.Upstream.Policy + } + if c.Upstream.MaxAttempts <= 0 { + c.Upstream.MaxAttempts = d.Upstream.MaxAttempts + } + if c.Upstream.BreakerThreshold <= 0 { + c.Upstream.BreakerThreshold = d.Upstream.BreakerThreshold + } + if c.Upstream.BreakerCooldown <= 0 { + c.Upstream.BreakerCooldown = d.Upstream.BreakerCooldown + } + if c.Cache.Size <= 0 { + c.Cache.Size = d.Cache.Size + } + if c.Cache.Shards <= 0 { + c.Cache.Shards = d.Cache.Shards + } + c.Cache.Shards = pow2(c.Cache.Shards) + if c.Cache.MaxTTL <= 0 { + c.Cache.MaxTTL = d.Cache.MaxTTL + } + if c.Cache.NegativeTTL <= 0 { + c.Cache.NegativeTTL = d.Cache.NegativeTTL + } + if c.Cache.Persist.Interval <= 0 { + c.Cache.Persist.Interval = d.Cache.Persist.Interval + } + if c.Block.DefaultAction == "" { + c.Block.DefaultAction = d.Block.DefaultAction + } + if c.Block.ResponseTTL <= 0 { + c.Block.ResponseTTL = d.Block.ResponseTTL + } + if c.Block.Parallelism <= 0 { + c.Block.Parallelism = d.Block.Parallelism + } + if c.ACL.DefaultPolicy == "" { + c.ACL.DefaultPolicy = d.ACL.DefaultPolicy + } + if c.QueryLog.MaxEntries <= 0 { + c.QueryLog.MaxEntries = d.QueryLog.MaxEntries + } + if c.QueryLog.FlushInterval <= 0 { + c.QueryLog.FlushInterval = d.QueryLog.FlushInterval + } + if c.QueryLog.LiveBuffer <= 0 { + c.QueryLog.LiveBuffer = d.QueryLog.LiveBuffer + } + if c.QueryLog.SampleRate <= 0 { + c.QueryLog.SampleRate = 1 + } + if c.QueryLog.SampleRate > 1 { + c.QueryLog.SampleRate = 1 + } + if c.Cluster.Valkey.Prefix == "" { + c.Cluster.Valkey.Prefix = d.Cluster.Valkey.Prefix + } + if c.Cluster.Valkey.LeaderTTL <= 0 { + c.Cluster.Valkey.LeaderTTL = d.Cluster.Valkey.LeaderTTL + } + if c.Cluster.PeerTTL <= 0 { + c.Cluster.PeerTTL = d.Cluster.PeerTTL + } + if len(c.API.Listen) == 0 && len(c.API.TLSListen) == 0 { + c.API.Listen = d.API.Listen + } + if c.API.BasePath == "" { + c.API.BasePath = d.API.BasePath + } + c.API.BasePath = "/" + strings.Trim(c.API.BasePath, "/") + if c.API.Auth.Mode == "" { + c.API.Auth.Mode = d.API.Auth.Mode + } + if c.API.Auth.SessionTTL <= 0 { + c.API.Auth.SessionTTL = d.API.Auth.SessionTTL + } + if c.Metrics.Path == "" { + c.Metrics.Path = d.Metrics.Path + } + if !strings.HasPrefix(c.Metrics.Path, "/") { + c.Metrics.Path = "/" + c.Metrics.Path + } + if c.Log.Level == "" { + c.Log.Level = d.Log.Level + } + if c.Log.Format == "" { + c.Log.Format = d.Log.Format + } + // Normalise list sources and upstream weights. + for i := range c.Block.Lists { + l := &c.Block.Lists[i] + if l.Format == "" { + l.Format = "auto" + } + if l.Refresh == 0 { + l.Refresh = Duration(24 * time.Hour) + } + if l.Timeout <= 0 { + l.Timeout = c.Server.Timeouts.List + } + if l.MaxBytes <= 0 { + l.MaxBytes = 512 << 20 + } + } + for i := range c.Upstream.Servers { + u := &c.Upstream.Servers[i] + if u.Proto == "" { + u.Proto = "udp" + } + if u.Weight <= 0 { + u.Weight = 1 + } + if _, _, err := net.SplitHostPort(u.Addr); err != nil { + u.Addr = net.JoinHostPort(u.Addr, defaultPort(u.Proto)) + } + } +} + +func defaultPort(proto string) string { + switch proto { + case "tls": + return "853" + case "tcp", "udp": + return "53" + } + return "53" +} + +func pow2(n int) int { + if n <= 1 { + return 1 + } + p := 1 + for p < n { + p <<= 1 + } + return p +} + +// EDNS is a tiny accessor so callers can treat a possibly-zero value safely. +func (c *Config) EDNS() EDNS { return c.DNS.EDNS } +func (c *Config) EDNSBits() bool { return c.DNS.EDNS.ClientSubnet } + +// Validate reports every problem it can find, aggregated, so an operator +// fixing a config file sees all of it at once. +func (c *Config) Validate() error { + var errs []error + add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) } + + if c.Server.MaxConcurrent <= 0 { + add("server.max_concurrent must be > 0") + } + checkAddrs := func(field string, list []string) { + for _, a := range list { + if _, _, err := net.SplitHostPort(a); err != nil { + add("%s: %q is not host:port", field, a) + } + } + } + checkAddrs("dns.udp", c.DNS.UDP) + checkAddrs("dns.tcp", c.DNS.TCP) + checkAddrs("dns.dot.listen", c.DNS.DoT.Listen) + checkAddrs("dns.doh.listen", c.DNS.DoH.Listen) + checkAddrs("api.listen", c.API.Listen) + checkAddrs("api.tls_listen", c.API.TLSListen) + + if c.DNS.MaxUDPSize < 512 || c.DNS.MaxUDPSize > 4096 { + add("dns.max_udp_size must be between 512 and 4096") + } + switch c.Upstream.Policy { + case "parallel", "random", "failover", "": + default: + add("upstream.policy must be parallel, random or failover (got %q)", c.Upstream.Policy) + } + if len(c.Upstream.Servers) == 0 { + add("upstream.servers must not be empty") + } + seen := map[string]bool{} + for i, u := range c.Upstream.Servers { + if u.Addr == "" { + add("upstream.servers[%d]: addr is required", i) + } + if seen[u.Addr+"/"+u.Proto] { + add("upstream.servers[%d]: duplicate %s %s", i, u.Proto, u.Addr) + } + seen[u.Addr+"/"+u.Proto] = true + switch u.Proto { + case "udp", "tcp", "tls": + default: + add("upstream.servers[%d]: proto must be udp, tcp or tls (got %q)", i, u.Proto) + } + if u.Proto == "tls" && u.ServerName == "" { + host, _, _ := net.SplitHostPort(u.Addr) + u.ServerName = host + } + } + for i, b := range c.Upstream.Bootstraps { + if b.Name == "" { + add("upstream.bootstraps[%d]: name is required", i) + } + for _, a := range b.Addresses { + if net.ParseIP(a) == nil { + add("upstream.bootstraps[%d]: %q is not an IP address", i, a) + } + } + } + + switch c.Block.DefaultAction { + case "nxdomain", "zero", "redirect", "drop", "refused": + default: + add("blocklist.default_action must be nxdomain, zero, redirect, drop or refused (got %q)", c.Block.DefaultAction) + } + if c.Block.DefaultAction == "zero" { + if c.Block.ResponseIP == "" { + c.Block.ResponseIP = "0.0.0.0" + } + if net.ParseIP(c.Block.ResponseIP) == nil { + add("blocklist.response_ip %q is not an IP", c.Block.ResponseIP) + } + if c.Block.ResponseIPv6 == "" { + c.Block.ResponseIPv6 = "::" + } + if net.ParseIP(c.Block.ResponseIPv6) == nil { + add("blocklist.response_ipv6 %q is not an IP", c.Block.ResponseIPv6) + } + } + if c.Block.DefaultAction == "redirect" && c.Block.RedirectName == "" { + add("blocklist.redirect_name is required when default_action is redirect") + } + names := map[string]bool{} + for i, l := range c.Block.Lists { + if l.Name == "" { + add("blocklist.lists[%d]: name is required", i) + } + if names[l.Name] { + add("blocklist.lists[%d]: duplicate list name %q", i, l.Name) + } + names[l.Name] = true + if l.URL == "" && l.Path == "" { + add("blocklist.lists[%d] %q: one of url or path is required", i, l.Name) + } + if l.URL != "" { + if !strings.HasPrefix(l.URL, "https://") && !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "file://") { + add("blocklist.lists[%d] %q: url must be http(s) or file", i, l.Name) + } + if strings.HasPrefix(l.URL, "http://") && l.InsecureSkipTLSVerify == false && false { + add("blocklist.lists[%d] %q: plain http is discouraged", i, l.Name) + } + } + switch l.Format { + case "auto", "hosts", "adblock", "plain", "rpz", "": + default: + add("blocklist.lists[%d] %q: unknown format %q", i, l.Name, l.Format) + } + if l.Refresh < 0 { + add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name) + } + } + if c.Block.Enabled { + if len(c.Block.Lists) == 0 && len(c.Block.LocalZones) == 0 && + len(c.Block.BlockEntries) == 0 && len(c.Block.AllowEntries) == 0 { + add("blocklist is enabled but no lists, local_zones or inline entries are configured") + } + } + + switch c.ACL.DefaultPolicy { + case "allow", "deny", "block_only": + default: + add("acl.default_policy must be allow, deny or block_only (got %q)", c.ACL.DefaultPolicy) + } + groups := map[string]bool{} + for i, g := range c.ACL.Groups { + if g.Name == "" { + add("acl.groups[%d]: name is required", i) + } + if groups[g.Name] { + add("acl.groups[%d]: duplicate group name %q", i, g.Name) + } + groups[g.Name] = true + switch g.Policy { + case "allow", "deny", "block_only": + default: + add("acl.groups[%d] %q: policy must be allow, deny or block_only (got %q)", i, g.Name, g.Policy) + } + for _, cidr := range g.Match.CIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + add("acl.groups[%d] %q: bad CIDR %q", i, g.Name, cidr) + } + } + for _, s := range g.Match.PTRSuffixes { + if !strings.HasSuffix(s, ".") { + s += "." + g.Match.PTRSuffixes[i] = s + } + } + if g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 { + add("acl.groups[%d] %q: match.any is set but is also the fallback; make it the last group", i, g.Name) + } + } + if c.QueryLog.SampleRate < 0 || c.QueryLog.SampleRate > 1 { + add("qlog.sample_rate must be between 0 and 1") + } + + if c.Cluster.Enabled { + if len(c.Cluster.Valkey.Addrs) == 0 { + add("cluster.valkey.addrs must not be empty when cluster.enabled is true") + } + } + switch c.API.Auth.Mode { + case "none", "apikey", "basic": + default: + add("api.auth.mode must be none, apikey or basic (got %q)", c.API.Auth.Mode) + } + if c.API.Auth.Mode == "apikey" && len(c.API.Auth.APIKeys) == 0 && !c.API.Auth.TrustLoopback { + add("api.auth.mode is apikey but api.auth.api_keys is empty and trust_loopback is false: the API would be unauthenticated") + } + if c.API.Auth.Mode == "basic" && c.API.Auth.Username == "" { + add("api.auth.mode is basic but api.auth.username is empty") + } + if c.API.Auth.SessionTTL <= 0 { + add("api.auth.session_ttl must be > 0") + } + for _, p := range c.API.TrustedProxies { + if _, _, err := net.ParseCIDR(p); err != nil { + add("api.trusted_proxies: bad CIDR %q", p) + } + } + + switch c.Log.Level { + case "debug", "info", "warn", "error": + default: + add("log.level must be debug, info, warn or error (got %q)", c.Log.Level) + } + switch c.Log.Format { + case "text", "json": + default: + add("log.format must be text or json (got %q)", c.Log.Format) + } + + if len(errs) > 0 { + sort.SliceStable(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() }) + return errors.Join(errs...) + } + return nil +} + +// Save atomically writes the configuration back to path as YAML. +func (c *Config) Save(path string) error { + data, err := Marshal(c) + if err != nil { + return err + } + return WriteFileAtomic(path, data, 0o600) +} + +// Marshal renders the config as YAML with a leading header comment. +func Marshal(c *Config) ([]byte, error) { + var sb strings.Builder + sb.WriteString("# fancy-dns configuration\n") + sb.WriteString("# Managed with care: the admin API rewrites this file, so keep it in git.\n") + enc := yaml.NewEncoder(&sb) + enc.SetIndent(2) + if err := enc.Encode(c); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return []byte(sb.String()), nil +} + +// WriteFileAtomic writes data to path via a temp file + rename so readers +// never observe a partial document. +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, perm); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// AbsPath resolves p against the config's working directory when relative. +func (c *Config) AbsPath(p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + wd := c.Server.WorkingDir + if wd == "" { + wd = "." + } + return filepath.Join(wd, p) +} + +// CachePath returns the resolved bbolt cache path. +func (c *Config) CachePath() string { + if c.Cache.Persist.Path != "" { + return c.AbsPath(c.Cache.Persist.Path) + } + return c.AbsPath("data/cache.db") +} + +// QueryLogPath returns the resolved bbolt query-log path. +func (c *Config) QueryLogPath() string { + if c.QueryLog.Path != "" { + return c.AbsPath(c.QueryLog.Path) + } + return c.AbsPath("data/queries.db") +} + +// ListPath returns the on-disk path for a list source, or "" when the source +// is URL-only. +func (c *Config) ListPath(l ListSource) string { + if l.Path == "" { + return "" + } + return c.AbsPath(l.Path) +} + +// HasAPIAuth reports whether any authentication is enforced at all. +func (c *Config) HasAPIAuth() bool { + switch c.API.Auth.Mode { + case "none": + return false + case "apikey": + return len(c.API.Auth.APIKeys) > 0 || len(c.API.Auth.ReadOnlyAPIKeys) > 0 || !c.API.Auth.TrustLoopback + case "basic": + return true + } + return false +} + +// ClientIPs parses ACL group CIDRs into prefixes, skipping invalid input +// (Validate already reported them). +func (g Group) Prefixes() []netip.Prefix { + out := make([]netip.Prefix, 0, len(g.Match.CIDRs)) + for _, c := range g.Match.CIDRs { + p, err := netip.ParsePrefix(c) + if err != nil { + continue + } + out = append(out, p) + } + return out +} diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000..b7a9e38 --- /dev/null +++ b/internal/config/store.go @@ -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") +) diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1a76ce4 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,1126 @@ +// Package engine is the query pipeline: the single place where a DNS question +// is turned into an answer. +// +// The order of operations matters and is the whole design: +// +// rate limit -> ACL policy -> local zone -> blocklist -> cache -> upstream +// +// Everything in front of the cache is cheap and lock-light; the cache is a +// single shard-local lookup; only a miss reaches the network. The pipeline is +// a plain method call chain rather than a plugin stack, because the hot path +// runs millions of times a second and a plugin registry is pure overhead. +package engine + +import ( + "context" + "net/netip" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/dns" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/acl" + "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/config" + "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" +) + +// Request describes one inbound query. +type Request struct { + // Msg is the parsed query. The engine may modify it (to attach an OPT or + // strip EDNS) so the transport can write it back out. + Msg *dns.Msg + // Remote is the client address. + Remote netip.Addr + // Transport is "udp", "tcp", "dot" or "doh", used for metrics labels and + // the log. + Transport string + // TCP reports whether the response may exceed the UDP size limit. + TCP bool + // Proto is the negotiated protocol string, kept for the log. + Proto string + // Start is when the request arrived. + Start time.Time + // ClientNames are reverse-DNS names for the client, if known. The server + // fills these in from its reverse cache so the ACL can match on names + // without a synchronous PTR lookup on the query path. + ClientNames []string + // ClientTags are derived labels for tag-based ACL matching. + ClientTags []string + // DoHPath records which DoH endpoint served the request, for the log. + DoHPath string +} + +// Response is the outcome plus the metadata the caller logs. +type Response struct { + Msg *dns.Msg + // Action is the pipeline classification. + Action qlog.Action + // Group is the matched ACL group. + Group string + // BlockSource is the blocklist/rule that blocked the query, for the log. + BlockSource string + // CacheHit reports whether the answer came from cache. + CacheHit bool + // CacheKind is "hit", "stale" or "miss". + CacheKind string + // Upstream is the address that answered. + Upstream string + // Duration is the total handling time. + Duration time.Duration + // Client is the resolved client string. + Client string + // Do reports whether the response carried the DNSSEC OK bit. + Do bool + // Truncated is set when the answer was shortened to fit the client buffer. + Truncated bool + // Drop is true when no response should be sent at all. + Drop bool + // GroupPolicy is the effective policy, for the UI. + GroupPolicy string +} + +// Engine holds the pipeline's swappable components. +type Engine struct { + cfgStore *config.Store + + acl atomic.Pointer[acl.Engine] + blocks *blocklist.Holder + cache *cache.Manager + aclH *acl.Holder + local *localzone.Holder + pool atomic.Pointer[resolver.Pool] + logger *qlog.Logger + reverse *ReverseCache + + // concurrency limiter: a buffered channel as a semaphore, which is + // cheaper than a sync.WaitGroup and gives us "try or fail" semantics. + // Held in an atomic pointer because a config swap replaces it while + // queries are in flight. + sem atomic.Pointer[chan struct{}] + semSize atomic.Int64 + + // perClient semaphores for clients with an explicit cap. + clientMu sync.Mutex + clientSems map[string]*clientLimiter + // rate limiters for clients with an explicit cap. + rateMu sync.Mutex + rateState map[string]*rateState + + // inflight is the current in-flight count for the gauge. + inflight atomic.Int64 + // dropped counts queries rejected by the concurrency cap. + dropped atomic.Uint64 + + // upstreamTimeout bounds a single upstream exchange. + upstreamTimeout atomic.Int64 + + // groupStats tracks per-group counters for the UI. + groupStats *groupStatTable + + // flusher is the app's flush callback, used by the admin API. + flusher atomic.Pointer[func()] +} + +type clientLimiter struct { + sem chan struct{} +} + +type rateState struct { + windowStart time.Time + count int +} + +// Options configures a new Engine. +type Options struct { + // Config is the hot-reloadable config store. + Config *config.Store + // Logger receives query entries; may be nil. + Logger *qlog.Logger + // Blocklists is the compiled blocklist holder. + Blocklists *blocklist.Holder + // Cache is the answer cache. + Cache *cache.Manager + // ACL is the compiled ACL engine. + ACL *acl.Holder + // Local is the local zone store. + Local *localzone.Holder + // Pool is the upstream pool. + Pool *resolver.Pool + // Reverse is the reverse-DNS cache used for name-based ACL matching. + Reverse *ReverseCache +} + +// New builds an Engine and wires the config-store hooks so a reload updates +// every swappable piece. +func New(opt Options) *Engine { + e := &Engine{ + cfgStore: opt.Config, + logger: opt.Logger, + blocks: opt.Blocklists, + cache: opt.Cache, + aclH: opt.ACL, + local: opt.Local, + reverse: opt.Reverse, + groupStats: newGroupStatTable(), + clientSems: make(map[string]*clientLimiter), + rateState: make(map[string]*rateState), + } + e.pool.Store(opt.Pool) + if e.cfgStore != nil { + cfg := e.cfgStore.Get() + e.applyConfig(cfg) + e.cfgStore.OnSwap(e.applyConfig) + } else if opt.Pool != nil { + e.upstreamTimeout.Store(int64(2 * time.Second)) + } + // Prefetching a stale entry re-runs the pipeline for just that key. + if e.cache != nil { + e.cache.SetPrefetcher(func(k cache.Key) { e.prefetch(k) }) + } + return e +} + +// applyConfig hot-swaps the components affected by a config change. +func (e *Engine) applyConfig(cfg *config.Config) { + semSize := cfg.Server.MaxConcurrent + if semSize < 1 { + semSize = 1024 + } + sem := make(chan struct{}, semSize) + e.sem.Store(&sem) + e.semSize.Store(int64(semSize)) + e.upstreamTimeout.Store(int64(cfg.Server.Timeouts.Upstre.D())) + + if e.aclH != nil { + e.aclH.Set(acl.Compile(cfg.ACL)) + metrics.SetACLGroups(len(cfg.ACL.Groups)) + } + if e.local != nil && len(cfg.Block.LocalZones) > 0 { + // Local zones are loaded asynchronously by the server; here we only + // record that a reload happened. + metrics.SetLocalRecords(e.local.Get().Count()) + } + if e.cache != nil { + e.cache.Resize(cfg.Cache.Size, cfg.Cache.Shards) + } + metrics.SetConcurrent(0) +} + +// Logger exposes the query logger (may be nil). +func (e *Engine) Logger() *qlog.Logger { return e.logger } + +// Cache exposes the answer cache (may be nil). +func (e *Engine) Cache() *cache.Manager { return e.cache } + +// Pool returns the current upstream pool. +func (e *Engine) Pool() *resolver.Pool { return e.pool.Load() } + +// Inflight is the number of queries currently being handled. +func (e *Engine) Inflight() int64 { return e.inflight.Load() } + +// Dropped is the number of queries rejected by the concurrency cap. +func (e *Engine) Dropped() uint64 { return e.dropped.Load() } + +// GroupStats returns per-group counters. +func (e *Engine) GroupStats() []acl.GroupSummary { + base := e.aclH.Get().Summaries() + return e.groupStats.merge(base) +} + +// Handle runs the full pipeline for one request and returns the response to +// send. It never returns nil: a synthetic SERVFAIL is the worst case. +func (e *Engine) Handle(ctx context.Context, req Request) Response { + start := req.Start + if start.IsZero() { + start = time.Now() + } + done := e.inflight.Add(1) + metrics.SetConcurrent(int(e.inflight.Load())) + defer func() { + e.inflight.Add(-1) + metrics.SetConcurrent(int(e.inflight.Load())) + _ = done + }() + + client := req.Remote.String() + decision := e.decide(req) + res := Response{Client: client, Group: decision.Group, GroupPolicy: string(decision.Policy)} + q := req.Msg + + // A malformed or empty query is answered with FORMERR before anything + // else looks at it. + if err := validate(q); err != nil { + res.Msg = formErr(q) + res.Action = qlog.ActionError + e.finish(req, res, start, decision) + return res + } + + qname := q.Question[0].Name + qtype := q.Question[0].Qtype + + // ACL deny wins over everything, including the local zone: an operator who + // denied a client means it. + if !decision.Allows() { + res.Msg = refuse(q, decision) + res.Action = qlog.ActionRefused + e.finish(req, res, start, decision) + return res + } + + // Concurrency cap. A per-client cap is checked first so one noisy client + // cannot exhaust the global budget. + if cl := decision.MaxConcurrent; cl > 0 { + if !e.acquireClient(client, cl) { + res.Msg = servFail(q, "client concurrency limit") + res.Action = qlog.ActionError + e.finish(req, res, start, decision) + return res + } + defer e.releaseClient(client) + } + // A nil semaphore means "no limit configured yet", which only happens + // before the first applyConfig; treat it as unbounded rather than dropping + // every query. + sem := e.sem.Load() + if sem != nil { + select { + case *sem <- struct{}{}: + defer func() { <-*sem }() + default: + e.dropped.Add(1) + res.Msg = servFail(q, "server overloaded") + res.Action = qlog.ActionError + e.finish(req, res, start, decision) + return res + } + } + + // Rate limiting. + if decision.RateLimit > 0 && !e.allowRate(client, decision.RateLimit) { + res.Msg = refuse(q, decision) + res.Action = qlog.ActionRefused + e.finish(req, res, start, decision) + return res + } + + // Group-level domain policy. + if e.groupBlocks(decision, qname) { + res.Msg = blockedResponse(q, e.blockCfg(), nil) + res.Action = qlog.ActionBlock + res.BlockSource = "acl:" + decision.Group + e.finish(req, res, start, decision) + return res + } + + // Local zone data. + if store := e.local.Get(); store != nil && store.Count() > 0 { + if local, ok := e.answerLocal(q, store); ok { + res.Msg = local + res.Action = qlog.ActionAllow + e.finish(req, res, start, decision) + return res + } + } + + // Blocklist. + var blocked *blocklist.Entry + if !decision.SkipBlocklist && e.blocks != nil { + if set := e.blocks.Get(); set != nil && set.Len() > 0 { + blocked = set.Match(qname) + } + } + if blocked != nil && !blocked.IsAllow() { + switch blocked.Action { + case blocklist.ActionDrop: + res.Action = qlog.ActionDrop + res.BlockSource = blocked.Source + res.Drop = true + res.Duration = time.Since(start) + e.finish(req, res, start, decision) + return res + default: + res.Msg = e.blockedMsg(q, blocked) + res.Action = qlog.ActionBlock + res.BlockSource = blocked.Source + res.CacheHit = false + e.finish(req, res, start, decision) + return res + } + } + + // Cache. A group that skips the cache still gets served from it on a + // prefetched entry only if the entry is fresh; we deliberately skip the + // lookup entirely to keep the guarantee honest. + if e.cache != nil && !decision.SkipCache { + key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0) + hit := e.cache.Get(key) + switch hit.Kind { + case cache.Fresh, cache.Stale: + res.Msg = hit.Msg + res.CacheHit = true + res.CacheKind = hit.Kind.String() + res.Upstream = hit.Upstream + if hit.Kind == cache.Stale { + metrics.RecordStaleHit() + } + res.Msg = e.finalize(res.Msg, q, req) + res.Action = qlog.ActionCached + if hit.Kind == cache.Stale { + // The cached answer is expired, so its TTL is 0; hand back a + // short one so clients do not cache the stale copy forever. + res.Msg.Answer = ageTo(res.Msg.Answer, 1) + res.Msg.Ns = ageTo(res.Msg.Ns, 1) + } + e.finish(req, res, start, decision) + return res + default: + metrics.RecordMiss() + } + } + + // Upstream. + pool := e.pool.Load() + if pool == nil { + res.Msg = servFail(q, "no upstream configured") + res.Action = qlog.ActionError + e.finish(req, res, start, decision) + return res + } + fwd := e.prepareUpstream(q, req) + upStart := time.Now() + up, addr, err := pool.Exchange(ctx, fwd, decision.Upstreams, time.Duration(e.upstreamTimeout.Load())) + res.Duration = time.Since(start) + if err != nil || up == nil { + metrics.RecordUpstreamError() + res.Msg = servFail(q, "upstream failure: "+shortErr(err)) + res.Action = qlog.ActionError + e.finish(req, res, start, decision) + return res + } + metrics.RecordUpstreamLatency(addr, time.Since(upStart)) + reply := e.fromUpstream(q, up, req) + res.Msg = reply + res.Upstream = addr + res.Action = qlog.ActionAllow + // A non-success RCODE is reported as an error through finish's metrics + // call, so the counters stay in one place. + if reply.Rcode != dns.RcodeServerFailure && reply.Rcode != dns.RcodeSuccess { + res.Action = qlog.ActionError + } else if reply.Rcode == dns.RcodeServerFailure { + res.Action = qlog.ActionError + } + if e.cache != nil && !decision.SkipCache { + key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0) + // Cache the untruncated upstream answer so a small client later does + // not poison the entry with a truncated copy. + if !up.Truncated { + e.cache.PutResponse(key, up, addr) + } + } + e.finish(req, res, start, decision) + return res +} + +// finish attaches duration, records metrics and writes the query log. +func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decision) { + if res.Duration == 0 { + res.Duration = time.Since(start) + } + metrics.Record(req.Transport, outcomeFor(res.Action), res.Duration) + if res.Action == qlog.ActionAllow && res.CacheHit { + metrics.Record(req.Transport, metrics.OutcomeCache, res.Duration) + } + e.groupStats.observe(d.Group, res) + if e.logger == nil { + return + } + if !e.logger.ShouldLog(res.Action) && !d.LogQueries { + return + } + entry := qlog.Entry{ + TS: start, + Client: res.Client, + Group: res.Group, + Duration: res.Duration.Milliseconds(), + Action: res.Action, + Upstream: res.Upstream, + List: res.BlockSource, + Protocol: req.Transport, + } + if res.Msg != nil { + entry.FromMsg(res.Msg) + } + e.logger.Log(entry) +} + +func outcomeFor(a qlog.Action) string { + switch a { + case qlog.ActionCached: + return metrics.OutcomeCache + case qlog.ActionBlock: + return metrics.OutcomeBlocked + case qlog.ActionError, qlog.ActionDrop: + return metrics.OutcomeError + case qlog.ActionRefused: + return metrics.OutcomeRefused + default: + return metrics.OutcomeUpstream + } +} + +// decide resolves the ACL decision for a request. +func (e *Engine) decide(req Request) acl.Decision { + eng := e.aclH.Get() + if eng == nil || eng.Count() == 0 { + return acl.Decision{Policy: acl.PolicyAllow, Default: true} + } + return eng.Match(acl.Client{ + IP: req.Remote, + Names: req.ClientNames, + Tags: req.ClientTags, + }) +} + +// groupBlocks applies a group's allow/deny domain lists. +func (e *Engine) groupBlocks(d acl.Decision, qname string) bool { + if len(d.DenyDomains) > 0 { + if acl.NewDomainSet(d.DenyDomains).Match(qname) { + return true + } + } + if len(d.AllowDomains) > 0 { + return !acl.NewDomainSet(d.AllowDomains).Match(qname) + } + return false +} + +// acquireClient takes a per-client slot, creating the limiter on demand. +func (e *Engine) acquireClient(client string, limit int) bool { + e.clientMu.Lock() + cl, ok := e.clientSems[client] + if !ok || cap(cl.sem) != limit { + cl = &clientLimiter{sem: make(chan struct{}, limit)} + e.clientSems[client] = cl + } + e.clientMu.Unlock() + select { + case cl.sem <- struct{}{}: + return true + default: + return false + } +} + +func (e *Engine) releaseClient(client string) { + e.clientMu.Lock() + cl, ok := e.clientSems[client] + e.clientMu.Unlock() + if !ok { + return + } + select { + case <-cl.sem: + default: + } +} + +// allowRate applies a fixed-window rate limit. A fixed window is fine here: +// the ACL cap exists to stop runaway clients, and it costs one map lookup. +func (e *Engine) allowRate(client string, perSec int) bool { + now := time.Now() + e.rateMu.Lock() + defer e.rateMu.Unlock() + st, ok := e.rateState[client] + if !ok { + st = &rateState{windowStart: now} + e.rateState[client] = st + } + if now.Sub(st.windowStart) >= time.Second { + st.windowStart = now + st.count = 0 + } + if st.count >= perSec { + return false + } + st.count++ + return true +} + +// answerLocal serves a question from the local zone store, converting a +// negative result into NXDOMAIN or NODATA with the zone's SOA. +func (e *Engine) answerLocal(q *dns.Msg, store *localzone.Store) (*dns.Msg, bool) { + if len(q.Question) == 0 { + return nil, false + } + question := q.Question[0] + res := store.Lookup(question) + if res.Delegated { + return nil, false + } + m := new(dns.Msg) + m.SetReply(q) + m.Authoritative = true + m.RecursionAvailable = true + if !res.Found { + // Only claim NXDOMAIN when we are actually authoritative for the name. + if res.Zone == "" { + return nil, false + } + if res.Authority != nil { + m.Rcode = dns.RcodeNameError + m.Ns = []dns.RR{res.Authority} + } else { + m.Rcode = dns.RcodeSuccess // NODATA without an SOA + } + return m, true + } + if len(res.Records) == 0 { + if res.Authority != nil { + m.Ns = []dns.RR{res.Authority} + } + return m, true + } + // CNAME answers for other types need the target resolved too; we do not + // chase here (the caller re-queries upstream if the target is not local), + // but a CNAME-only answer for a CNAME question is complete. + if len(m.Question) > 0 && m.Question[0].Qtype != res.Type { + if cname, ok := firstOfType(res.Records, dns.TypeCNAME); ok { + m.Answer = []dns.RR{cname} + return m, true + } + } + m.Answer = res.Records + return m, true +} + +func firstOfType(rrs []dns.RR, t uint16) (dns.RR, bool) { + for _, rr := range rrs { + if rr.Header().Rrtype == t { + return rr, true + } + } + return nil, false +} + +// blockedMsg synthesises the blocklist answer. +func (e *Engine) blockedMsg(q *dns.Msg, entry *blocklist.Entry) *dns.Msg { + cfg := e.blockCfg() + if entry != nil && entry.TTL > 0 { + c := cfg + c.ResponseTTL = config.Duration(entry.TTL) * config.Duration(time.Second) + cfg = c + } + if entry != nil { + switch entry.Action { + case blocklist.ActionNXDomain: + return nxDomain(q, uint32(cfg.ResponseTTL)) + case blocklist.ActionZero: + c := cfg + c.DefaultAction = "zero" + if entry.Response != "" { + c.ResponseIP = entry.Response + } + return sinkhole(q, c) + case blocklist.ActionRedirect: + return redirect(q, entry.Response, uint32(cfg.ResponseTTL)) + case blocklist.ActionCNAME: + return redirect(q, entry.Response, uint32(cfg.ResponseTTL)) + } + } + return blockedResponse(q, cfg, entry) +} + +func (e *Engine) blockCfg() config.Block { + if e.cfgStore == nil { + return config.Default().Block + } + return e.cfgStore.Get().Block +} + +// prepareUpstream builds the message to forward: the question plus the +// client's EDNS0 preferences, and nothing else (we never leak our own OPT to +// upstream, and we never forward the client's AD bit). +func (e *Engine) prepareUpstream(q *dns.Msg, req Request) *dns.Msg { + cfg := e.cfgStore.Get() + m := new(dns.Msg) + m.SetQuestion(q.Question[0].Name, q.Question[0].Qtype) + m.Question[0].Qclass = q.Question[0].Qclass + m.RecursionDesired = true + m.CheckingDisabled = q.CheckingDisabled + + var maxSize uint16 = cfg.DNS.MaxUDPSize + if o := q.IsEdns0(); o != nil { + if cfg.DNS.EDNS.PreserveEDNS0 { + for _, opt := range o.Option { + switch opt.Option() { + case dns.EDNS0SUBNET: + // Forward the client's ECS verbatim: the upstream uses it + // to pick a nearby anycast location, which is the whole + // point of the feature. + m.SetEdns0(maxSize, o.Do()) + if opt4, ok := opt.(*dns.EDNS0_SUBNET); ok { + appendECS(m, opt4) + } + continue + case dns.EDNS0PADDING, dns.EDNS0COOKIE: + // Cookie and padding are hop-by-hop; forwarding padding + // upstream is harmless, cookies are not. + if opt.Option() == dns.EDNS0PADDING { + m.SetEdns0(maxSize, o.Do()) + po := new(dns.EDNS0_PADDING) + po.Padding = opt4Padding(opt) + m.IsEdns0().Option = append(m.IsEdns0().Option, po) + } + continue + } + } + } + if o.Do() { + m.SetEdns0(maxSize, true) + } + if opt := o.Option; opt != nil { + for _, oo := range opt { + if oo.Option() == dns.EDNS0NSID { + // Our own NSID, not the client's. + if e.cfgStore == nil || !e.cfgStore.Get().DNS.HideIdentity { + nsid := new(dns.EDNS0_NSID) + nsid.Nsid = "fancy-dns" + if m.IsEdns0() == nil { + m.SetEdns0(maxSize, o.Do()) + } + m.IsEdns0().Option = append(m.IsEdns0().Option, nsid) + } + } + } + } + } + if m.IsEdns0() == nil { + m.SetEdns0(maxSize, false) + } + return m +} + +func appendECS(m *dns.Msg, src *dns.EDNS0_SUBNET) { + sub := new(dns.EDNS0_SUBNET) + sub.Code = src.Code + sub.Family = src.Family + sub.SourceNetmask = src.SourceNetmask + sub.SourceScope = src.SourceScope + sub.Address = src.Address + if m.IsEdns0() == nil { + m.SetEdns0(1232, false) + } + m.IsEdns0().Option = append(m.IsEdns0().Option, sub) +} + +func opt4Padding(opt dns.EDNS0) []byte { + if p, ok := opt.(*dns.EDNS0_PADDING); ok { + return p.Padding + } + return nil +} + +// fromUpstream converts an upstream answer into a client reply: the question +// and ID come from the client, the payload from upstream. +func (e *Engine) fromUpstream(q, up *dns.Msg, req Request) *dns.Msg { + m := new(dns.Msg) + m.SetReply(q) + m.Rcode = up.Rcode + if up.CheckingDisabled { + m.CheckingDisabled = true + } + if up.AuthenticatedData { + m.AuthenticatedData = true + } + if up.RecursionAvailable { + m.RecursionAvailable = true + } + m.Answer = up.Answer + m.Ns = up.Ns + // Keep the client's EDNS0 shape in the reply, but carry upstream's DO/extended + // rcode when present. + if o := up.IsEdns0(); o != nil { + m.SetEdns0(0, o.Do()) + if o.ExtendedRcode() != 0 || o.Version() != 0 { + ue := m.IsEdns0() + ue.SetExtendedRcode(uint16(o.ExtendedRcode())) + ue.SetVersion(o.Version()) + ue.SetDo(o.Do()) + } + } else if o := q.IsEdns0(); o != nil { + m.SetEdns0(0, o.Do()) + } else if e.cfgStore != nil && e.cfgStore.Get().DNS.MaxUDPSize > 512 { + m.SetEdns0(0, false) + } + if len(up.Question) > 0 { + m.Question = up.Question + } + return m +} + +// finalize prepares a response for the wire: attach EDNS0, apply the client's +// UDP size limit, and set the AD/CD bits as needed. +func (e *Engine) finalize(msg, q *dns.Msg, req Request) *dns.Msg { + if msg == nil { + return servFail(q, "nil response") + } + // Preserve the client's question verbatim (including the 0x20 case we may + // have canonicalised away). + if len(q.Question) > 0 { + msg.Question = q.Question + } + msg.Id = q.Id + msg.Response = true + if q.Opcode == dns.OpcodeQuery { + msg.RecursionDesired = q.RecursionDesired + } + if !req.TCP { + limit := clientUDPSize(q) + if limit > 0 && wouldOverflow(msg, limit) { + // RFC 2181 §9: TC means "ask again over TCP". Truncating to a + // question-only response is the correct, interoperable choice. + trunc := new(dns.Msg) + trunc.SetReply(q) + trunc.Truncated = true + if o := q.IsEdns0(); o != nil { + trunc.SetEdns0(0, o.Do()) + } + return trunc + } + } + return msg +} + +func clientUDPSize(q *dns.Msg) uint16 { + if o := q.IsEdns0(); o != nil { + if s := o.UDPSize(); s >= 512 { + return s + } + } + return 512 +} + +func wouldOverflow(m *dns.Msg, limit uint16) bool { + b, err := m.Pack() + if err != nil { + return true + } + return len(b) > int(limit) +} + +func ageTo(rrs []dns.RR, ttl uint32) []dns.RR { + for _, rr := range rrs { + rr.Header().Ttl = ttl + } + return rrs +} + +// prefetch re-resolves a stale key in the background. +func (e *Engine) prefetch(k cache.Key) { + if e.cache == nil { + return + } + pool := e.pool.Load() + if pool == nil { + return + } + q := new(dns.Msg) + q.SetQuestion(dns.Fqdn(k.Name), k.Type) + q.RecursionDesired = true + if k.DO { + q.SetEdns0(1232, true) + } + timeout := time.Duration(e.upstreamTimeout.Load()) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + up, addr, err := pool.Exchange(ctx, q, nil, timeout) + if err != nil || up == nil || up.Truncated { + return + } + e.cache.PutResponse(k, up, addr) +} + +// validate rejects malformed queries early. +func validate(q *dns.Msg) error { + if q == nil { + return errMalformed + } + if q.Response { + return errMalformed + } + if len(q.Question) != 1 { + return errMalformed + } + if q.Opcode != dns.OpcodeQuery && q.Opcode != dns.OpcodeUpdate { + return errMalformed + } + name := q.Question[0].Name + if name == "" || len(name) > 255*4 { + return errMalformed + } + return nil +} + +var errMalformed = errString("malformed query") + +type errString string + +func (e errString) Error() string { return string(e) } + +func formErr(q *dns.Msg) *dns.Msg { + m := new(dns.Msg) + m.SetRcode(q, dns.RcodeFormatError) + return m +} + +func servFail(q *dns.Msg, reason string) *dns.Msg { + m := new(dns.Msg) + m.SetRcode(q, dns.RcodeServerFailure) + return m +} + +func nxDomain(q *dns.Msg, ttl uint32) *dns.Msg { + m := new(dns.Msg) + m.SetRcode(q, dns.RcodeNameError) + m.Ns = []dns.RR{syntheticSOA(q, ttl)} + return m +} + +// syntheticSOA is the authority record attached to synthesised negative +// answers. RFC 2308 requires a SOA in NXDOMAIN's authority section for the TTL +// to be honoured, and clients use it to cache the negative result. +func syntheticSOA(q *dns.Msg, ttl uint32) dns.RR { + name := "." + if len(q.Question) > 0 { + labels := strings.Split(strings.TrimSuffix(q.Question[0].Name, "."), ".") + if len(labels) >= 2 { + name = strings.Join(labels[len(labels)-2:], ".") + "." + } + } + if ttl == 0 { + ttl = 60 + } + soa := &dns.SOA{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, + Ns: "ns.fancy-dns.local.", + Mbox: "hostmaster.fancy-dns.local.", + Serial: uint32(time.Now().Unix()), + Refresh: 3600, + Retry: 600, + Expire: 604800, + Minttl: ttl, + } + return soa +} + +// blockedResponse builds the configured blocklist answer. +func blockedResponse(q *dns.Msg, cfg config.Block, entry *blocklist.Entry) *dns.Msg { + ttl := uint32(cfg.ResponseTTL.D()) + if ttl == 0 { + ttl = 60 + } + switch cfg.DefaultAction { + case "drop": + // A drop is handled by the caller, which knows not to answer; reaching + // here means the caller wants a body, so return a timeout-style empty + // NOERROR. + m := new(dns.Msg) + m.SetReply(q) + return m + case "refused": + return refuseConfig(q) + case "zero": + return sinkhole(q, cfg) + case "redirect": + return redirect(q, cfg.RedirectName, ttl) + default: + return nxDomain(q, ttl) + } +} + +func refuseConfig(q *dns.Msg) *dns.Msg { + m := new(dns.Msg) + m.SetRcode(q, dns.RcodeRefused) + return m +} + +func refuse(q *dns.Msg, _ acl.Decision) *dns.Msg { return refuseConfig(q) } + +// sinkhole answers with the configured zero address, choosing the family from +// the question so a v4 client never gets a v6 answer. +func sinkhole(q *dns.Msg, cfg config.Block) *dns.Msg { + m := new(dns.Msg) + m.SetReply(q) + m.Authoritative = true + ttl := uint32(cfg.ResponseTTL.D()) + if ttl == 0 { + ttl = 60 + } + if len(q.Question) == 0 { + return m + } + switch q.Question[0].Qtype { + case dns.TypeA: + ip := cfg.ResponseIP + if ip == "" { + ip = "0.0.0.0" + } + rr, _ := dns.NewRR(q.Question[0].Name + " 0 IN A " + ip) + if rr != nil { + rr.Header().Ttl = ttl + m.Answer = []dns.RR{rr} + } + case dns.TypeAAAA: + ip := cfg.ResponseIPv6 + if ip == "" { + ip = "::" + } + rr, _ := dns.NewRR(q.Question[0].Name + " 0 IN AAAA " + ip) + if rr != nil { + rr.Header().Ttl = ttl + m.Answer = []dns.RR{rr} + } + default: + m.Ns = []dns.RR{syntheticSOA(q, ttl)} + } + return m +} + +// redirect answers with a CNAME to a configured name, plus an A/AAAA for the +// target when we know it (a CNAME to a name that does not resolve would be a +// worse answer than no CNAME at all). +func redirect(q *dns.Msg, target string, ttl uint32) *dns.Msg { + m := new(dns.Msg) + m.SetReply(q) + m.Authoritative = true + if target == "" { + return m + } + if len(q.Question) == 0 { + return m + } + cname, err := dns.NewRR(q.Question[0].Name + " 0 IN CNAME " + dns.Fqdn(target)) + if err != nil { + return m + } + cname.Header().Ttl = ttl + m.Answer = []dns.RR{cname} + switch q.Question[0].Qtype { + case dns.TypeA: + rr, _ := dns.NewRR(dns.Fqdn(target) + " 0 IN A 127.0.0.1") + if rr != nil { + rr.Header().Ttl = ttl + m.Answer = append(m.Answer, rr) + } + case dns.TypeAAAA: + rr, _ := dns.NewRR(dns.Fqdn(target) + " 0 IN AAAA ::1") + if rr != nil { + rr.Header().Ttl = ttl + m.Answer = append(m.Answer, rr) + } + } + return m +} + +func shortErr(err error) string { + if err == nil { + return "" + } + s := err.Error() + if len(s) > 120 { + s = s[:120] + } + return s +} + +// groupStatTable tracks per-group query and block counts for the UI. +type groupStatTable struct { + mu sync.RWMutex + stats map[string]*groupStat +} + +type groupStat struct { + queries uint64 + blocked uint64 + errors uint64 + cache uint64 +} + +func newGroupStatTable() *groupStatTable { + return &groupStatTable{stats: make(map[string]*groupStat)} +} + +func (t *groupStatTable) observe(group string, res Response) { + if group == "" { + group = "(default)" + } + t.mu.Lock() + s, ok := t.stats[group] + if !ok { + s = &groupStat{} + t.stats[group] = s + } + s.queries++ + switch res.Action { + case qlog.ActionBlock: + s.blocked++ + case qlog.ActionError, qlog.ActionDrop: + s.errors++ + case qlog.ActionCached: + s.cache++ + } + t.mu.Unlock() +} + +func (t *groupStatTable) merge(base []acl.GroupSummary) []acl.GroupSummary { + t.mu.RLock() + defer t.mu.RUnlock() + byName := make(map[string]acl.GroupSummary, len(base)) + for _, g := range base { + byName[g.Name] = g + } + // Include groups that were removed from the config but still have traffic. + for name := range t.stats { + if _, ok := byName[name]; ok { + continue + } + byName[name] = acl.GroupSummary{Name: name, Policy: string(acl.PolicyAllow)} + } + out := make([]acl.GroupSummary, 0, len(byName)) + for name, g := range byName { + if s, ok := t.stats[name]; ok { + g.QueryCount = s.queries + g.BlockedCount = s.blocked + } + out = append(out, g) + } + return out +} + +var _ = strconv.Itoa + +// Flush persists buffered state: the cache snapshot and the query log. It is +// wired to the server's flusher interface so the admin API can force a flush. +func (e *Engine) Flush() { + if p := e.flusher.Load(); p != nil { + (*p)() + } +} + +// SetFlusher installs the flush callback (set once during app wiring). +func (e *Engine) SetFlusher(fn func()) { + if fn == nil { + e.flusher.Store(nil) + return + } + e.flusher.Store(&fn) +} diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go new file mode 100644 index 0000000..c416c76 --- /dev/null +++ b/internal/engine/reverse.go @@ -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() +} diff --git a/internal/localzone/store.go b/internal/localzone/store.go new file mode 100644 index 0000000..137dd20 --- /dev/null +++ b/internal/localzone/store.go @@ -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) } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..fcd4094 --- /dev/null +++ b/internal/metrics/metrics.go @@ -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 +} diff --git a/internal/qlog/counts.go b/internal/qlog/counts.go new file mode 100644 index 0000000..425e149 --- /dev/null +++ b/internal/qlog/counts.go @@ -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 +} diff --git a/internal/qlog/qlog.go b/internal/qlog/qlog.go new file mode 100644 index 0000000..9db6b73 --- /dev/null +++ b/internal/qlog/qlog.go @@ -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 +} diff --git a/internal/resolver/pool.go b/internal/resolver/pool.go new file mode 100644 index 0000000..f458a34 --- /dev/null +++ b/internal/resolver/pool.go @@ -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 + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..78a17c7 --- /dev/null +++ b/internal/server/server.go @@ -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 ".". + 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() + } +} diff --git a/internal/server/stats.go b/internal/server/stats.go new file mode 100644 index 0000000..4d12894 --- /dev/null +++ b/internal/server/stats.go @@ -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"` +} diff --git a/internal/web/dist/.gitkeep b/internal/web/dist/.gitkeep new file mode 100644 index 0000000..1d40ad1 --- /dev/null +++ b/internal/web/dist/.gitkeep @@ -0,0 +1 @@ +Build artifacts land here: run `make web` (npm ci && npm run build) then rebuild the binary. diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html new file mode 100644 index 0000000..b671cb9 --- /dev/null +++ b/internal/web/dist/index.html @@ -0,0 +1,3 @@ +fancy-dns +

fancy-dns

+

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

diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..fde306c --- /dev/null +++ b/internal/web/web.go @@ -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 = ` +fancy-dns + +

fancy-dns

+

The admin API is running, but the web console was not built into this binary.

+

Build it with make web (or cd web && npm ci && npm run build), then rebuild the binary.

+

The JSON API is available under /api.

` + +// 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" +} diff --git a/internal/zonefile/zonefile.go b/internal/zonefile/zonefile.go new file mode 100644 index 0000000..cc8d9e0 --- /dev/null +++ b/internal/zonefile/zonefile.go @@ -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) +} diff --git a/web/.codesearch/codesearch.db b/web/.codesearch/codesearch.db new file mode 100644 index 0000000..cf25c66 Binary files /dev/null and b/web/.codesearch/codesearch.db differ diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -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? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -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 }] + } +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/web/README.md @@ -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. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5e3836a --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + web + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..2fbb040 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2488 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "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" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7e802ab --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons.svg b/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/web/src/App.css @@ -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); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..9ca8e8d --- /dev/null +++ b/web/src/App.tsx @@ -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 ( + <> +
+
+ + React logo + Vite logo +
+
+

Get started

+

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

+
+ +
+ +
+ +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Vite community

+ +
+
+ +
+
+ + ) +} + +export default App diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web/src/assets/hero.png differ diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..5d504e6 --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -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 ( +
+ {/* Desktop rail toggle lives in the top bar; on mobile the bar carries + the hamburger that opens the sheet instead. */} +
+ {isMobile ? ( + + ) : ( + + )} + +
+ + + fancy-dns +
+ +
+ {nodeId !== undefined && nodeId !== '' && ( + + {nodeId} + + )} + {health !== undefined && } + + +
+
+ + {/* Desktop sidebar. */} + + + {/* Mobile sheet. */} + {sheetOpen && ( +
+ +
+ + +
+ )} + +
+
+ {children} +
+
+ + ) +} + +/** Re-exported so pages can label collapsed rail items without importing ui. */ +export { Tooltip } diff --git a/web/src/components/layout/HealthPill.tsx b/web/src/components/layout/HealthPill.tsx new file mode 100644 index 0000000..5d85689 --- /dev/null +++ b/web/src/components/layout/HealthPill.tsx @@ -0,0 +1,32 @@ +import type { Health } from '../../lib/types.ts' +import { cn } from '../../lib/cn.ts' + +const STYLES: Record = { + 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 = { + 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 ( + + + {label ?? DEFAULT_LABEL[health] ?? health} + + ) +} diff --git a/web/src/components/layout/LoadingBlock.tsx b/web/src/components/layout/LoadingBlock.tsx new file mode 100644 index 0000000..c2e29e1 --- /dev/null +++ b/web/src/components/layout/LoadingBlock.tsx @@ -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 ( +
+ Loading… + {Array.from({ length: count }, (_, index) => ( +
+ + + +
+ ))} +
+ ) +} diff --git a/web/src/components/layout/PageError.tsx b/web/src/components/layout/PageError.tsx new file mode 100644 index 0000000..c087cd8 --- /dev/null +++ b/web/src/components/layout/PageError.tsx @@ -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 ( +
+
+
+ {onRetry && ( + + )} +
+ ) +} diff --git a/web/src/components/layout/PageHeader.tsx b/web/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..3a59aa2 --- /dev/null +++ b/web/src/components/layout/PageHeader.tsx @@ -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 ( +
+
+

{title}

+ {description !== undefined && ( +

{description}

+ )} +
+ {actions !== undefined && ( +
{actions}
+ )} +
+ ) +} diff --git a/web/src/components/layout/ThemeToggle.tsx b/web/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..3753269 --- /dev/null +++ b/web/src/components/layout/ThemeToggle.tsx @@ -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(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 ( + + ) +} diff --git a/web/src/components/layout/index.ts b/web/src/components/layout/index.ts new file mode 100644 index 0000000..01ec264 --- /dev/null +++ b/web/src/components/layout/index.ts @@ -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' diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..d5f3a81 --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -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 = { + 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 ( + + {children} + + ) +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..cc967dd --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -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 = { + 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 = { + 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 { + 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 ( + + ) +} diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..60cbfac --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -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 ( +
+ {hasHeader && ( + + {title !== undefined && {title}} + {title === undefined && subtitle !== undefined && ( +

{subtitle}

+ )} +
+ )} + {children} +
+ ) +} + +export function CardHeader({ + title, + subtitle, + actions, + children, + className, +}: { + title?: ReactNode + subtitle?: ReactNode + actions?: ReactNode + children?: ReactNode + className?: string +}) { + return ( +
+
+ {title !== undefined && {title}} + {children} +
+ {actions !== undefined &&
{actions}
} +
+ ) +} + +export function CardTitle({ + children, + subtitle, + className, +}: { + children?: ReactNode + subtitle?: ReactNode + className?: string +}) { + return ( +
+

{children}

+ {subtitle !== undefined &&

{subtitle}

} +
+ ) +} + +export function CardBody({ + children, + className, + padded = true, +}: { + children?: ReactNode + className?: string + padded?: boolean +}) { + return ( +
{children}
+ ) +} + +export function CardFooter({ children, className }: { children?: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/web/src/components/ui/ConfirmDialog.tsx b/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..49dac30 --- /dev/null +++ b/web/src/components/ui/ConfirmDialog.tsx @@ -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 + title: ReactNode + message: ReactNode + confirmLabel?: string + destructive?: boolean + pending?: boolean +}) { + return ( + + + + + } + > +

{message}

+
+ ) +} diff --git a/web/src/components/ui/CopyButton.tsx b/web/src/components/ui/CopyButton.tsx new file mode 100644 index 0000000..70f6cc4 --- /dev/null +++ b/web/src/components/ui/CopyButton.tsx @@ -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 | 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 ( + + ) +} diff --git a/web/src/components/ui/Dialog.tsx b/web/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..ba024eb --- /dev/null +++ b/web/src/components/ui/Dialog.tsx @@ -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 ``: 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(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): 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 ( + { + 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], + )} + > +
+
+

+ {title} +

+ {description !== undefined && ( +

+ {description} +

+ )} +
+ +
+
{children}
+ {footer !== undefined && ( +
+ {footer} +
+ )} +
+ ) +} diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..09d0ab0 --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -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 ( +
+ + +
+

{title}

+ {description !== undefined && ( +

{description}

+ )} +
+ {action !== undefined &&
{action}
} +
+ ) +} diff --git a/web/src/components/ui/IconButton.tsx b/web/src/components/ui/IconButton.tsx new file mode 100644 index 0000000..5ea70b1 --- /dev/null +++ b/web/src/components/ui/IconButton.tsx @@ -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, '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 ( + + + + ) +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..5bac87f --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -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 ( +
+ {label !== undefined && ( + + )} + {children} + {error !== undefined && error !== null && error !== '' ? ( + + ) : hint !== undefined ? ( +

+ {hint} +

+ ) : null} +
+ ) +} + +export interface InputProps extends InputHTMLAttributes { + 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 = ( +
+ {Icon && ( +
+ ) + + if (label === undefined && hint === undefined && error === undefined) return input + return ( + + {input} + + ) +} diff --git a/web/src/components/ui/Kbd.tsx b/web/src/components/ui/Kbd.tsx new file mode 100644 index 0000000..86407bb --- /dev/null +++ b/web/src/components/ui/Kbd.tsx @@ -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 ( + + {children} + + ) +} diff --git a/web/src/components/ui/Pagination.tsx b/web/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..d7dfc1e --- /dev/null +++ b/web/src/components/ui/Pagination.tsx @@ -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 ( +
+

+ {total === 0 ? 'no rows' : `${first}–${last} of ${total.toLocaleString('en-US')}`} +

+
+ + + {current} / {pages} + + +
+
+ ) +} diff --git a/web/src/components/ui/Progress.tsx b/web/src/components/ui/Progress.tsx new file mode 100644 index 0000000..7a3a961 --- /dev/null +++ b/web/src/components/ui/Progress.tsx @@ -0,0 +1,51 @@ +import { cn } from '../../lib/cn.ts' + +export type ProgressTone = 'default' | 'ok' | 'warn' | 'danger' | 'info' + +const TONES: Record = { + 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 ( +
+
+
+
+ {showLabel && ( + + {pct.toFixed(0)}% + + )} +
+ ) +} diff --git a/web/src/components/ui/SectionTitle.tsx b/web/src/components/ui/SectionTitle.tsx new file mode 100644 index 0000000..d3053d4 --- /dev/null +++ b/web/src/components/ui/SectionTitle.tsx @@ -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 ( +
+
+

{title}

+ {description !== undefined && ( +

{description}

+ )} +
+ {actions !== undefined && ( +
{actions}
+ )} +
+ ) +} diff --git a/web/src/components/ui/Select.tsx b/web/src/components/ui/Select.tsx new file mode 100644 index 0000000..6c3a0df --- /dev/null +++ b/web/src/components/ui/Select.tsx @@ -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 { + 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 = ( +
+ +
+ ) + + if (label === undefined && hint === undefined && error === undefined) return select + return ( + + {select} + + ) +} diff --git a/web/src/components/ui/Skeleton.tsx b/web/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..49f4e9f --- /dev/null +++ b/web/src/components/ui/Skeleton.tsx @@ -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 ( +