initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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, ".")
|
||||
}
|
||||
@@ -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) }
|
||||
Vendored
+209
@@ -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
|
||||
}
|
||||
Vendored
+362
@@ -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)
|
||||
}
|
||||
Vendored
+596
@@ -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
|
||||
}
|
||||
Vendored
+251
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
// Package cluster keeps several fancy-dns instances in agreement using
|
||||
// Valkey (Redis protocol): configuration changes are broadcast, blocklist
|
||||
// digests are compared so a lagging instance can be spotted, and one instance
|
||||
// is elected to fetch lists so N instances do not hammer the same mirror N
|
||||
// times.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// EventKind names a broadcast message.
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
// EventConfig carries a full configuration document.
|
||||
EventConfig EventKind = "config"
|
||||
// EventReload asks peers to re-read the config file from disk (the
|
||||
// normal case when the file is managed by git or an operator).
|
||||
EventReload EventKind = "reload"
|
||||
// EventBlocklists asks the leader to refresh list sources.
|
||||
EventBlocklists EventKind = "blocklists"
|
||||
// EventHeartbeat publishes this instance's state.
|
||||
EventHeartbeat EventKind = "heartbeat"
|
||||
// EventBye announces a clean shutdown.
|
||||
EventBye EventKind = "bye"
|
||||
)
|
||||
|
||||
// Event is a cluster message.
|
||||
type Event struct {
|
||||
Kind EventKind `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
At time.Time `json:"at"`
|
||||
Version int64 `json:"version,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
State *NodeState `json:"state,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// NodeState is what an instance publishes about itself.
|
||||
type NodeState struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Addr string `json:"addr"`
|
||||
Version string `json:"version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Generation uint64 `json:"generation"`
|
||||
// BlocklistDigest lets peers detect that they hold different rules.
|
||||
BlocklistDigest string `json:"blocklist_digest"`
|
||||
BlocklistRules int `json:"blocklist_rules"`
|
||||
BlocklistBuilt time.Time `json:"blocklist_built_at"`
|
||||
ConfigDigest string `json:"config_digest"`
|
||||
Listeners []string `json:"listeners"`
|
||||
// Leader is true when this node holds the list-refresh lease.
|
||||
Leader bool `json:"leader"`
|
||||
}
|
||||
|
||||
// Handler receives cluster events from peers.
|
||||
type Handler func(context.Context, Event)
|
||||
|
||||
// Options configures a Cluster.
|
||||
type Options struct {
|
||||
// Config is the hot-reloadable config store.
|
||||
Config *config.Store
|
||||
// Logger is the structured logger.
|
||||
Logger *slog.Logger
|
||||
// Version is the running build's version string.
|
||||
Version string
|
||||
// StartedAt is this process's start time.
|
||||
StartedAt time.Time
|
||||
// Addr is this instance's advertised admin address.
|
||||
Addr string
|
||||
// Handler receives events from peers (and from this instance's own
|
||||
// publishes, so a caller can hook both without two code paths).
|
||||
Handler Handler
|
||||
// StateFunc supplies the live state published with heartbeats.
|
||||
StateFunc func() *NodeState
|
||||
// Digests is called on peer state to compare rules.
|
||||
Digests func() (blocklist string, rules int, built time.Time, cfgDigest string)
|
||||
}
|
||||
|
||||
// Cluster is the Valkey-backed coordination layer. A disabled Cluster is a
|
||||
// valid, fully-functional no-op: every method is safe to call.
|
||||
type Cluster struct {
|
||||
cfg *config.Store
|
||||
log *slog.Logger
|
||||
opt Options
|
||||
node string
|
||||
|
||||
client atomic.Pointer[redis.Client]
|
||||
// connected is true while the pubsub loop is healthy.
|
||||
connected atomic.Bool
|
||||
// leader is true while this instance holds the refresh lease.
|
||||
leader atomic.Bool
|
||||
// lastEvent is when a peer message last arrived, for the health check.
|
||||
lastEvent atomic.Int64
|
||||
// seen is the last state per node id.
|
||||
seenMu sync.RWMutex
|
||||
seen map[string]NodeState
|
||||
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New builds a Cluster. Call Start to connect.
|
||||
func New(opt Options) *Cluster {
|
||||
log := opt.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
c := &Cluster{
|
||||
cfg: opt.Config,
|
||||
log: log.With("component", "cluster"),
|
||||
opt: opt,
|
||||
node: opt.Version,
|
||||
seen: make(map[string]NodeState),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
if cfg := opt.Config.Get(); cfg != nil && cfg.Cluster.NodeID != "" {
|
||||
c.node = cfg.Cluster.NodeID
|
||||
}
|
||||
if c.node == "" {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "fancy-dns"
|
||||
}
|
||||
c.node = host
|
||||
}
|
||||
c.node = c.node + "-" + shortID()
|
||||
return c
|
||||
}
|
||||
|
||||
func shortID() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := cryptoRead(b); err != nil {
|
||||
return "0000"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// NodeID is this instance's cluster identity.
|
||||
func (c *Cluster) NodeID() string { return c.node }
|
||||
|
||||
// Enabled reports whether clustering is configured on.
|
||||
func (c *Cluster) Enabled() bool {
|
||||
if c == nil || c.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return c.cfg.Get().Cluster.Enabled
|
||||
}
|
||||
|
||||
// Connected reports whether the pubsub connection is up.
|
||||
func (c *Cluster) Connected() bool { return c.connected.Load() }
|
||||
|
||||
// IsLeader reports whether this instance holds the refresh lease.
|
||||
func (c *Cluster) IsLeader() bool { return c.leader.Load() }
|
||||
|
||||
// Start connects and subscribes, then keeps the connection alive until ctx is
|
||||
// cancelled. A Valkey outage is not fatal: the server runs standalone and
|
||||
// reconnects in the background.
|
||||
func (c *Cluster) Start(ctx context.Context) {
|
||||
if !c.Enabled() {
|
||||
c.log.Info("cluster disabled")
|
||||
return
|
||||
}
|
||||
cfg := c.cfg.Get()
|
||||
cl, err := c.dial(cfg)
|
||||
if err != nil {
|
||||
c.log.Warn("cluster: cannot connect, running standalone", "error", err)
|
||||
return
|
||||
}
|
||||
c.client.Store(cl)
|
||||
c.connected.Store(true)
|
||||
c.log.Info("cluster connected", "addrs", cfg.Cluster.Valkey.Addrs, "node", c.node)
|
||||
|
||||
c.publishState(ctx)
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
c.maintain(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// dial builds a Valkey client from config.
|
||||
func (c *Cluster) dial(cfg *config.Config) (*redis.Client, error) {
|
||||
v := cfg.Cluster.Valkey
|
||||
cl := redis.NewClient(&redis.Options{
|
||||
Addr: strings.Join(v.Addrs, ","),
|
||||
Username: v.Username,
|
||||
Password: v.Password,
|
||||
DB: v.DB,
|
||||
PoolSize: max(1, v.PoolSize),
|
||||
DialTimeout: v.DialTimeout.D(),
|
||||
ReadTimeout: v.ReadTimeout.D(),
|
||||
WriteTimeout: v.ReadTimeout.D(),
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := cl.Ping(ctx).Err(); err != nil {
|
||||
_ = cl.Close()
|
||||
return nil, err
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// maintain runs the pubsub receive loop and the periodic heartbeat, reconnecting
|
||||
// as needed.
|
||||
func (c *Cluster) maintain(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil || c.stopping() {
|
||||
return
|
||||
}
|
||||
if err := c.runSession(ctx); err != nil {
|
||||
c.log.Warn("cluster session ended, retrying", "error", err)
|
||||
c.connected.Store(false)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cluster) stopping() bool {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// runSession subscribes and dispatches until the connection drops.
|
||||
func (c *Cluster) runSession(ctx context.Context) error {
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return errors.New("cluster: no client")
|
||||
}
|
||||
sub := cl.Subscribe(ctx, c.channel(EventHeartbeat), c.channel(EventConfig), c.channel(EventReload), c.channel(EventBlocklists), c.channel(EventBye))
|
||||
defer func() { _ = sub.Close() }()
|
||||
|
||||
// Confirm the subscription is live before declaring the session healthy.
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
return fmt.Errorf("cluster: subscribe: %w", err)
|
||||
}
|
||||
c.connected.Store(true)
|
||||
c.log.Info("cluster subscribed", "channel", c.channel(EventHeartbeat))
|
||||
|
||||
hb := time.NewTicker(c.cfg.Get().Cluster.PeerTTL.D() / 2)
|
||||
defer hb.Stop()
|
||||
|
||||
ch := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-c.stop:
|
||||
return nil
|
||||
case <-hb.C:
|
||||
c.publishState(ctx)
|
||||
c.renewLease(ctx)
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return errors.New("cluster: subscription closed")
|
||||
}
|
||||
var ev Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &ev); err != nil {
|
||||
continue // not ours / malformed; ignore rather than crash
|
||||
}
|
||||
if ev.NodeID == c.node {
|
||||
continue // our own echo
|
||||
}
|
||||
c.lastEvent.Store(time.Now().Unix())
|
||||
if ev.State != nil {
|
||||
c.seenMu.Lock()
|
||||
c.seen[ev.NodeID] = *ev.State
|
||||
c.seenMu.Unlock()
|
||||
}
|
||||
if ev.Kind == EventBye {
|
||||
c.seenMu.Lock()
|
||||
delete(c.seen, ev.NodeID)
|
||||
c.seenMu.Unlock()
|
||||
continue
|
||||
}
|
||||
if c.opt.Handler != nil {
|
||||
c.opt.Handler(ctx, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cluster) channel(k EventKind) string {
|
||||
prefix := c.cfg.Get().Cluster.Valkey.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "fancydns"
|
||||
}
|
||||
return prefix + ":events:" + string(k)
|
||||
}
|
||||
|
||||
func (c *Cluster) heartbeatKey() string {
|
||||
prefix := c.cfg.Get().Cluster.Valkey.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "fancydns"
|
||||
}
|
||||
return prefix + ":nodes"
|
||||
}
|
||||
|
||||
// State returns this instance's current state.
|
||||
func (c *Cluster) State() *NodeState {
|
||||
st := &NodeState{
|
||||
NodeID: c.node,
|
||||
Addr: c.opt.Addr,
|
||||
Version: c.opt.Version,
|
||||
StartedAt: c.opt.StartedAt,
|
||||
Generation: c.cfg.Generation(),
|
||||
Leader: c.leader.Load(),
|
||||
}
|
||||
if f := c.opt.StateFunc; f != nil {
|
||||
if live := f(); live != nil {
|
||||
live.NodeID = c.node
|
||||
live.Leader = c.leader.Load()
|
||||
st = live
|
||||
}
|
||||
}
|
||||
if d := c.opt.Digests; d != nil {
|
||||
bl, rules, built, cfgDigest := d()
|
||||
st.BlocklistDigest = bl
|
||||
st.BlocklistRules = rules
|
||||
st.BlocklistBuilt = built
|
||||
st.ConfigDigest = cfgDigest
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// publishState broadcasts this instance's heartbeat.
|
||||
func (c *Cluster) publishState(ctx context.Context) {
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return
|
||||
}
|
||||
st := c.State()
|
||||
ev := Event{Kind: EventHeartbeat, NodeID: c.node, At: time.Now(), State: st}
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pipe := cl.Pipeline()
|
||||
pipe.Publish(ctx, c.channel(EventHeartbeat), data)
|
||||
// Also keep a keyed record so a late-joining instance can discover the
|
||||
// existing set without waiting a full heartbeat interval.
|
||||
pipe.HSet(ctx, c.heartbeatKey(), c.node, mustJSON(st))
|
||||
pipe.Expire(ctx, c.heartbeatKey(), c.cfg.Get().Cluster.PeerTTL.D()*4)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Peers returns the last known state of other instances.
|
||||
func (c *Cluster) Peers() []NodeState {
|
||||
c.seenMu.RLock()
|
||||
defer c.seenMu.RUnlock()
|
||||
out := make([]NodeState, 0, len(c.seen))
|
||||
for _, s := range c.seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
|
||||
return out
|
||||
}
|
||||
|
||||
// BroadcastConfig publishes a full configuration document to peers.
|
||||
func (c *Cluster) BroadcastConfig(ctx context.Context, cfg *config.Config) error {
|
||||
cl := c.client.Load()
|
||||
if !c.Enabled() || cl == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := config.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev := Event{Kind: EventConfig, NodeID: c.node, At: time.Now(), Version: cfg.Version, Config: string(data)}
|
||||
payload, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.Publish(ctx, c.channel(EventConfig), payload).Err()
|
||||
}
|
||||
|
||||
// BroadcastReload asks peers to re-read their config file from disk.
|
||||
func (c *Cluster) BroadcastReload(ctx context.Context) error {
|
||||
cl := c.client.Load()
|
||||
if !c.Enabled() || cl == nil {
|
||||
return nil
|
||||
}
|
||||
ev := Event{Kind: EventReload, NodeID: c.node, At: time.Now()}
|
||||
payload, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.Publish(ctx, c.channel(EventReload), payload).Err()
|
||||
}
|
||||
|
||||
// BroadcastRefresh asks the leader to refresh list sources. The names slice is
|
||||
// advisory: an empty slice means "everything".
|
||||
func (c *Cluster) BroadcastRefresh(ctx context.Context, names []string) error {
|
||||
cl := c.client.Load()
|
||||
if !c.Enabled() || cl == nil {
|
||||
return nil
|
||||
}
|
||||
ev := Event{Kind: EventBlocklists, NodeID: c.node, At: time.Now(), Sources: names}
|
||||
payload, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.Publish(ctx, c.channel(EventBlocklists), payload).Err()
|
||||
}
|
||||
|
||||
// Digest computes a stable digest of a configuration document.
|
||||
func Digest(cfg *config.Config) string {
|
||||
data, err := config.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(data)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// leaseKey is the Valkey key guarding the list-refresh leader.
|
||||
func (c *Cluster) leaseKey() string {
|
||||
prefix := c.cfg.Get().Cluster.Valkey.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "fancydns"
|
||||
}
|
||||
return prefix + ":leader"
|
||||
}
|
||||
|
||||
// renewLease acquires or extends the refresh lease. Only the leader fetches
|
||||
// lists; followers serve their compiled copy and keep their cache warm, so a
|
||||
// fleet of N nodes does not multiply the load on a blocklist mirror.
|
||||
func (c *Cluster) renewLease(ctx context.Context) {
|
||||
cl := c.client.Load()
|
||||
if cl == nil || !c.Enabled() {
|
||||
c.leader.Store(false)
|
||||
return
|
||||
}
|
||||
ttl := c.cfg.Get().Cluster.Valkey.LeaderTTL.D()
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
// SET NX with a TTL: only the first caller wins the lease. Everyone else
|
||||
// just extends it when they already hold it.
|
||||
ok, err := cl.SetNX(ctx, c.leaseKey(), c.node, ttl).Result()
|
||||
if err != nil {
|
||||
c.leader.Store(false)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
c.leader.Store(true)
|
||||
return
|
||||
}
|
||||
holder, err := c.leaseHolder(ctx)
|
||||
if err == nil && holder == c.node {
|
||||
// We already hold it: extend.
|
||||
_ = cl.Expire(ctx, c.leaseKey(), ttl).Err()
|
||||
c.leader.Store(true)
|
||||
return
|
||||
}
|
||||
c.leader.Store(false)
|
||||
}
|
||||
|
||||
// leaseHolder reads who holds the refresh lease.
|
||||
func (c *Cluster) leaseHolder(ctx context.Context) (string, error) {
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return "", errors.New("no client")
|
||||
}
|
||||
v, err := cl.Get(ctx, c.leaseKey()).Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// IsLeader reports the current lease holder, for the UI.
|
||||
func (c *Cluster) LeaseHolder(ctx context.Context) string {
|
||||
if !c.Enabled() {
|
||||
return ""
|
||||
}
|
||||
h, err := c.leaseHolder(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Status is the cluster view for the API.
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
NodeID string `json:"node_id"`
|
||||
Leader bool `json:"leader"`
|
||||
LeaseOwner string `json:"lease_owner,omitempty"`
|
||||
Peers []NodeState `json:"peers"`
|
||||
// Divergent lists peers whose blocklist digest differs from ours, which
|
||||
// is the one thing an operator really needs to see.
|
||||
Divergent []string `json:"divergent"`
|
||||
Channel string `json:"channel"`
|
||||
LastEvent time.Time `json:"last_event,omitempty"`
|
||||
}
|
||||
|
||||
// Status snapshots the cluster state.
|
||||
func (c *Cluster) Status(ctx context.Context) Status {
|
||||
st := Status{
|
||||
Enabled: c.Enabled(),
|
||||
Connected: c.Connected(),
|
||||
NodeID: c.node,
|
||||
Leader: c.leader.Load(),
|
||||
Channel: c.channel(EventHeartbeat),
|
||||
}
|
||||
if c.Enabled() {
|
||||
st.LeaseOwner = c.LeaseHolder(ctx)
|
||||
}
|
||||
st.Peers = c.Peers()
|
||||
own := c.State()
|
||||
for _, p := range st.Peers {
|
||||
if p.BlocklistDigest != "" && own.BlocklistDigest != "" && p.BlocklistDigest != own.BlocklistDigest {
|
||||
st.Divergent = append(st.Divergent, p.NodeID)
|
||||
}
|
||||
}
|
||||
sort.Strings(st.Divergent)
|
||||
if v := c.lastEvent.Load(); v > 0 {
|
||||
st.LastEvent = time.Unix(v, 0)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Discover lists the node ids currently registered, for a fresh instance that
|
||||
// has not heard a heartbeat yet.
|
||||
func (c *Cluster) Discover(ctx context.Context) []NodeState {
|
||||
if !c.Enabled() {
|
||||
return nil
|
||||
}
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return nil
|
||||
}
|
||||
fields, err := cl.HGetAll(ctx, c.heartbeatKey()).Result()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]NodeState, 0, len(fields))
|
||||
ids := make([]string, 0, len(fields))
|
||||
for id := range fields {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
if id == c.node {
|
||||
continue
|
||||
}
|
||||
var st NodeState
|
||||
if err := json.Unmarshal([]byte(fields[id]), &st); err == nil {
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stop closes the connection and announces the departure.
|
||||
func (c *Cluster) Stop() {
|
||||
select {
|
||||
case <-c.stop:
|
||||
default:
|
||||
close(c.stop)
|
||||
}
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
ev := Event{Kind: EventBye, NodeID: c.node, At: time.Now()}
|
||||
if payload, err := json.Marshal(ev); err == nil {
|
||||
_ = cl.Publish(ctx, c.channel(EventBye), payload).Err()
|
||||
}
|
||||
// Only release the lease if we hold it, so a shutdown does not hand the
|
||||
// job to a node that is also going away.
|
||||
if c.leader.Load() {
|
||||
cl.Eval(ctx, `if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`, []string{c.leaseKey()}, c.node)
|
||||
}
|
||||
_ = cl.Close()
|
||||
c.connected.Store(false)
|
||||
c.leader.Store(false)
|
||||
}
|
||||
|
||||
// String renders the node id for logs.
|
||||
func (c *Cluster) String() string { return strings.TrimSpace(c.node) }
|
||||
|
||||
func cryptoRead(b []byte) (int, error) {
|
||||
h := sha1.Sum([]byte(time.Now().String() + string(b)))
|
||||
copy(b, h[:4])
|
||||
return len(b), nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,126 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Store is the process-wide, hot-reloadable configuration holder. Readers take
|
||||
// a snapshot pointer (cheap, no lock) and writers swap a fully-built *Config
|
||||
// in atomically, so a request never observes a half-applied config.
|
||||
type Store struct {
|
||||
cur atomic.Pointer[Config]
|
||||
mu sync.Mutex // serialises writers so LastError/Version stay coherent
|
||||
// path is the file the config was loaded from, used by the API to
|
||||
// persist mutations.
|
||||
path string
|
||||
// lastError records why the most recent reload attempt failed, so the
|
||||
// health endpoint can report a stale-but-running state.
|
||||
lastErr atomic.Pointer[string]
|
||||
// generation increments on every successful swap.
|
||||
generation atomic.Uint64
|
||||
// hooks run after a successful swap (server re-binds listeners etc).
|
||||
hooksMu sync.RWMutex
|
||||
hooks []func(*Config)
|
||||
}
|
||||
|
||||
// NewStore wraps cfg.
|
||||
func NewStore(cfg *Config, path string) *Store {
|
||||
s := &Store{path: path}
|
||||
s.cur.Store(cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
// Get returns the current configuration snapshot.
|
||||
func (s *Store) Get() *Config { return s.cur.Load() }
|
||||
|
||||
// Path is the file this config was loaded from ("" when running without one).
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
// Generation counts successful swaps; the cluster uses it to detect
|
||||
// divergence between instances.
|
||||
func (s *Store) Generation() uint64 { return s.generation.Load() }
|
||||
|
||||
// LastError returns the error from the most recent failed reload, or nil.
|
||||
func (s *Store) LastError() string {
|
||||
if p := s.lastErr.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Swap installs cfg as the current configuration. Callers are responsible for
|
||||
// validating first; Swap itself only guards structural invariants that would
|
||||
// otherwise panic subsystems.
|
||||
func (s *Store) Swap(cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errNilConfig
|
||||
}
|
||||
if cfg.Cache.Shards <= 0 {
|
||||
cfg.Cache.Shards = 32
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cur.Store(cfg)
|
||||
s.generation.Add(1)
|
||||
s.lastErr.Store(nil)
|
||||
s.mu.Unlock()
|
||||
s.runHooks(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload re-reads the backing file and swaps it in on success. On failure the
|
||||
// previous configuration stays active and the error is recorded.
|
||||
func (s *Store) Reload() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.path == "" {
|
||||
return errNoPath
|
||||
}
|
||||
cfg, err := Load(s.path)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
s.lastErr.Store(&msg)
|
||||
return err
|
||||
}
|
||||
// Preserve the working dir of the running instance so relative paths keep
|
||||
// resolving the same way even if the file moved.
|
||||
cfg.Server.WorkingDir = s.cur.Load().Server.WorkingDir
|
||||
s.cur.Store(cfg)
|
||||
s.generation.Add(1)
|
||||
s.lastErr.Store(nil)
|
||||
s.runHooks(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnSwap registers a callback invoked with the new config after each swap.
|
||||
// Hooks run outside the writer lock; a panicking hook is contained so one bad
|
||||
// subsystem cannot take down a reload.
|
||||
func (s *Store) OnSwap(fn func(*Config)) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.hooksMu.Lock()
|
||||
s.hooks = append(s.hooks, fn)
|
||||
s.hooksMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) runHooks(cfg *Config) {
|
||||
s.hooksMu.RLock()
|
||||
hooks := s.hooks
|
||||
s.hooksMu.RUnlock()
|
||||
for _, h := range hooks {
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
h(cfg)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
type storeError string
|
||||
|
||||
func (e storeError) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
errNilConfig = storeError("config: refusing to swap a nil configuration")
|
||||
errNoPath = storeError("config: no config file path known, cannot reload")
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
// Package server runs the DNS listeners: plain DNS on UDP/TCP, DNS-over-TLS
|
||||
// (RFC 7858) and DNS-over-HTTPS (RFC 8484).
|
||||
//
|
||||
// All four transports funnel into one handler, so a query behaves identically
|
||||
// whichever way it arrived. The transports are deliberately thin: parse, hand
|
||||
// to the engine, write. Anything heavier belongs behind the engine.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
|
||||
)
|
||||
|
||||
// Handler is the interface the transports use to answer a query.
|
||||
type Handler interface {
|
||||
Handle(ctx context.Context, req engine.Request) engine.Response
|
||||
Inflight() int64
|
||||
Flush()
|
||||
}
|
||||
|
||||
// identityFunc resolves a client IP to its reverse-DNS names. The app installs
|
||||
// it so the server does not depend on the engine's cache directly.
|
||||
type identityFunc func(ip string) []string
|
||||
|
||||
// tagFunc derives ACL tags from a client IP.
|
||||
type tagFunc func(ip string) []string
|
||||
|
||||
// Server owns every DNS listener.
|
||||
type Server struct {
|
||||
eng Handler
|
||||
cfg *config.Store
|
||||
log *slog.Logger
|
||||
version string
|
||||
|
||||
names atomic.Pointer[identityFunc]
|
||||
tags atomic.Pointer[tagFunc]
|
||||
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
shutdown context.CancelFunc
|
||||
wait sync.WaitGroup
|
||||
listeners []listener
|
||||
// counters is keyed by "<transport>.<field>".
|
||||
counters sync.Map
|
||||
// active is the listener snapshot the API serves.
|
||||
active atomic.Pointer[[]ListenerInfo]
|
||||
}
|
||||
|
||||
// listener couples a close function with the info the API reports for it.
|
||||
type listener struct {
|
||||
close func() error
|
||||
info ListenerInfo
|
||||
}
|
||||
|
||||
// ListenerInfo describes one bound listener.
|
||||
type ListenerInfo struct {
|
||||
Transport string `json:"transport"`
|
||||
Addr string `json:"addr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
// Engine handles queries.
|
||||
Engine Handler
|
||||
// Config is the hot-reloadable config store; the server subscribes to it.
|
||||
Config *config.Store
|
||||
// Logger is the structured logger.
|
||||
Logger *slog.Logger
|
||||
// Version is reported in the DoH server header and the admin API.
|
||||
Version string
|
||||
}
|
||||
|
||||
// New builds a Server.
|
||||
func New(opt Options) *Server {
|
||||
log := opt.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
eng: opt.Engine,
|
||||
cfg: opt.Config,
|
||||
log: log.With("component", "dns"),
|
||||
version: opt.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// SetIdentity installs the client reverse-name and tag resolvers.
|
||||
func (s *Server) SetIdentity(names identityFunc, tags tagFunc) {
|
||||
s.names.Store(&names)
|
||||
s.tags.Store(&tags)
|
||||
}
|
||||
|
||||
// Start binds every enabled listener and begins serving. It returns once all
|
||||
// listeners have been bound, so a bind failure surfaces synchronously.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
cfg := s.cfg.Get()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
cancel()
|
||||
return errors.New("server: already started")
|
||||
}
|
||||
s.started = true
|
||||
s.shutdown = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
var failures []error
|
||||
for _, addr := range cfg.DNS.UDP {
|
||||
if err := s.serveUDP(ctx, addr); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
for _, addr := range cfg.DNS.TCP {
|
||||
if err := s.serveTCP(ctx, addr); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
if cfg.DNS.DoT.Enabled {
|
||||
if err := s.serveDoT(ctx, cfg); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
if cfg.DNS.DoH.Enabled {
|
||||
if err := s.serveDoH(ctx, cfg); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
s.publish()
|
||||
if err := errors.Join(failures...); err != nil {
|
||||
s.log.Warn("some listeners failed to start", "error", err)
|
||||
}
|
||||
if s.count() == 0 && len(failures) > 0 {
|
||||
cancel()
|
||||
return fmt.Errorf("server: no listener started: %w", errors.Join(failures...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.listeners)
|
||||
}
|
||||
|
||||
func (s *Server) add(l listener) {
|
||||
s.mu.Lock()
|
||||
s.listeners = append(s.listeners, l)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) publish() {
|
||||
s.mu.Lock()
|
||||
infos := make([]ListenerInfo, 0, len(s.listeners))
|
||||
for _, l := range s.listeners {
|
||||
infos = append(infos, l.info)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.active.Store(&infos)
|
||||
}
|
||||
|
||||
// Listeners returns the current listener snapshot.
|
||||
func (s *Server) Listeners() []ListenerInfo {
|
||||
if p := s.active.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts every listener down and waits for their goroutines.
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if !s.started {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.started = false
|
||||
shutdown := s.shutdown
|
||||
ls := s.listeners
|
||||
s.listeners = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if shutdown != nil {
|
||||
shutdown()
|
||||
}
|
||||
var errs []error
|
||||
for _, l := range ls {
|
||||
if err := l.close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wait.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
errs = append(errs, ctx.Err())
|
||||
}
|
||||
s.publish()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// serveUDP binds a UDP listener. One short-lived goroutine per packet keeps
|
||||
// lock hold times at zero; a large socket buffer absorbs bursts instead of
|
||||
// dropping them.
|
||||
func (s *Server) serveUDP(ctx context.Context, addr string) error {
|
||||
pc, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind udp %s: %w", addr, err)
|
||||
}
|
||||
if uc, ok := pc.(*net.UDPConn); ok {
|
||||
_ = uc.SetReadBuffer(4 << 20)
|
||||
_ = uc.SetWriteBuffer(4 << 20)
|
||||
}
|
||||
// The read deadline is what lets the loop notice cancellation promptly;
|
||||
// without it a blocked ReadFrom would outlive shutdown. It has to be reset
|
||||
// on every iteration: a deadline set once expires for good and turns the
|
||||
// loop into a spin that never reads another packet.
|
||||
const readTick = 500 * time.Millisecond
|
||||
s.add(listener{close: pc.Close, info: ListenerInfo{
|
||||
Transport: "udp", Addr: pc.LocalAddr().String(), Enabled: true, Running: true,
|
||||
}})
|
||||
s.log.Info("dns listener up", "transport", "udp", "addr", pc.LocalAddr().String())
|
||||
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
buf := make([]byte, dns.MaxMsgSize)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
_ = pc.SetReadDeadline(time.Now().Add(readTick))
|
||||
n, from, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
// A closed listener during shutdown lands here.
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
s.log.Debug("udp read", "error", err)
|
||||
continue
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
addr, _ := net.ResolveUDPAddr("udp", from.String())
|
||||
go s.servePacket(ctx, pc, pkt, from, addr)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.As(err, &ne) && ne.Timeout()
|
||||
}
|
||||
|
||||
// servePacket answers one UDP query. The listener is passed in so the reply
|
||||
// goes out on the very same socket the query arrived on: a fresh socket per
|
||||
// answer would burn an ephemeral port and a connect handshake per query.
|
||||
func (s *Server) servePacket(ctx context.Context, pc net.PacketConn, pkt []byte, from net.Addr, to *net.UDPAddr) {
|
||||
m := new(dns.Msg)
|
||||
if err := m.Unpack(pkt); err != nil {
|
||||
// An unparsable message cannot be replied to: there is no ID and no
|
||||
// question to echo. Dropping it is the only correct response.
|
||||
s.bump("udp", "errors")
|
||||
return
|
||||
}
|
||||
// A message with no question is still answerable (FORMERR), so it goes
|
||||
// through the engine's validation rather than being dropped here.
|
||||
// Cap the client's advertised buffer: nobody needs 64 KiB of DNS, and an
|
||||
// unbounded value is a cheap amplification vector.
|
||||
if o := m.IsEdns0(); o != nil && o.UDPSize() > 4096 {
|
||||
o.SetUDPSize(4096)
|
||||
}
|
||||
req := s.request(ctx, m, from, "udp")
|
||||
res := s.eng.Handle(ctx, req)
|
||||
s.bump("udp", "queries")
|
||||
s.classify("udp", res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
s.bump("udp", "dropped")
|
||||
return
|
||||
}
|
||||
if to == nil {
|
||||
return
|
||||
}
|
||||
out := s.fitUDP(res.Msg, m, 512)
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if c, ok := pc.(interface{ SetWriteDeadline(time.Time) error }); ok {
|
||||
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
}
|
||||
s.bump("udp", "sent")
|
||||
_, _ = pc.WriteTo(out, to)
|
||||
}
|
||||
|
||||
// fitUDP packs a reply into a UDP datagram, setting TC and returning a
|
||||
// question-only response when it does not fit (RFC 1035 §4.2.1, RFC 2181 §9).
|
||||
func (s *Server) fitUDP(res *dns.Msg, q *dns.Msg, floor int) []byte {
|
||||
limit := floor
|
||||
if o := q.IsEdns0(); o != nil {
|
||||
if n := int(o.UDPSize()); n >= 512 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
out, err := res.Pack()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(out) <= limit {
|
||||
return out
|
||||
}
|
||||
tc := new(dns.Msg)
|
||||
tc.SetReply(q)
|
||||
tc.Rcode = res.Rcode
|
||||
tc.Truncated = true
|
||||
tc.AuthenticatedData = res.AuthenticatedData
|
||||
if o := q.IsEdns0(); o != nil {
|
||||
tc.SetEdns0(uint16(limit), o.Do())
|
||||
}
|
||||
out, err = tc.Pack()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
s.bump("udp", "truncated")
|
||||
return out
|
||||
}
|
||||
|
||||
// request builds an engine.Request, filling in the client's identity.
|
||||
func (s *Server) request(ctx context.Context, m *dns.Msg, from net.Addr, transport string) engine.Request {
|
||||
ip := addrToAddr(from)
|
||||
key := ip.String()
|
||||
req := engine.Request{
|
||||
Msg: m,
|
||||
Remote: ip,
|
||||
Transport: transport,
|
||||
Start: time.Now(),
|
||||
}
|
||||
if f := s.names.Load(); f != nil {
|
||||
req.ClientNames = (*f)(key)
|
||||
}
|
||||
if f := s.tags.Load(); f != nil {
|
||||
req.ClientTags = (*f)(key)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// classify records per-transport outcome counters.
|
||||
func (s *Server) classify(transport string, res engine.Response) {
|
||||
switch res.Action {
|
||||
case "block":
|
||||
s.bump(transport, "blocked")
|
||||
case "cached":
|
||||
s.bump(transport, "cached")
|
||||
case "error", "drop":
|
||||
s.bump(transport, "errors")
|
||||
}
|
||||
}
|
||||
|
||||
// serveTCP binds a TCP listener for plain DNS.
|
||||
func (s *Server) serveTCP(ctx context.Context, addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind tcp %s: %w", addr, err)
|
||||
}
|
||||
return s.serveStream(ctx, ln, "tcp", nil, 0)
|
||||
}
|
||||
|
||||
// serveStream runs the accept loop shared by TCP and DoT.
|
||||
func (s *Server) serveStream(ctx context.Context, ln net.Listener, transport string, tlsCfg *tls.Config, idleTimeout time.Duration) error {
|
||||
if tlsCfg != nil {
|
||||
ln = tls.NewListener(ln, tlsCfg)
|
||||
}
|
||||
s.add(listener{close: ln.Close, info: ListenerInfo{
|
||||
Transport: transport, Addr: ln.Addr().String(), Enabled: true, Running: true,
|
||||
}})
|
||||
s.log.Info("dns listener up", "transport", transport, "addr", ln.Addr().String())
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.serveConn(ctx, conn, transport, idleTimeout)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveConn handles one stream connection with RFC 1035 §4.2.2 length
|
||||
// prefixes, so a client may pipeline several queries.
|
||||
func (s *Server) serveConn(ctx context.Context, conn net.Conn, transport string, idleTimeout time.Duration) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
co := &dns.Conn{Conn: conn}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
m, err := co.ReadMsg()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
s.bump(transport, "errors")
|
||||
return
|
||||
}
|
||||
req := s.request(ctx, m, conn.RemoteAddr(), transport)
|
||||
req.TCP = true
|
||||
res := s.eng.Handle(ctx, req)
|
||||
s.bump(transport, "queries")
|
||||
s.classify(transport, res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
s.bump(transport, "dropped")
|
||||
return
|
||||
}
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
if err := co.WriteMsg(res.Msg); err != nil {
|
||||
return
|
||||
}
|
||||
s.bump(transport, "sent")
|
||||
}
|
||||
}
|
||||
|
||||
// serveDoT binds the DNS-over-TLS listener.
|
||||
func (s *Server) serveDoT(ctx context.Context, cfg *config.Config) error {
|
||||
tlsCfg, err := tlsConfig(cfg.DNS.DoT.CertFile, cfg.DNS.DoT.KeyFile, cfg.DNS.DoT.ClientCA, cfg.DNS.DoT.MinVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dot: %w", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
return errors.New("dot: enabled but no certificate configured")
|
||||
}
|
||||
tlsCfg.NextProtos = []string{"dot"}
|
||||
timeout := cfg.Server.Timeouts.TLS.D()
|
||||
var errs []error
|
||||
for _, addr := range cfg.DNS.DoT.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("bind dot %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
if err := s.serveStream(ctx, ln, "dot", tlsCfg, timeout); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// serveDoH binds the DNS-over-HTTPS listener. GET (with ?dns=) and POST
|
||||
// (application/dns-message) are both supported per RFC 8484, and the handler
|
||||
// speaks h2 with an h2c fallback so it also works on a plain TCP listener.
|
||||
func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
|
||||
base := normaliseBase(cfg.DNS.DoH.BasePath)
|
||||
mux := http.NewServeMux()
|
||||
h := s.doHHandler(base, cfg.DNS.DoH.AllowPost)
|
||||
mux.Handle(base, h)
|
||||
// RFC 8484 §4.1 reserves /.well-known/dns-query as an alias.
|
||||
if base != "/dns-query" {
|
||||
mux.Handle("/.well-known/dns-query", h)
|
||||
}
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
h2s := &http2.Server{IdleTimeout: 90 * time.Second}
|
||||
handler := h2c.NewHandler(mux, h2s)
|
||||
|
||||
tlsCfg, tlsErr := tlsConfig(cfg.DNS.DoH.CertFile, cfg.DNS.DoH.KeyFile, "", cfg.DNS.DoH.MinVersion)
|
||||
if tlsCfg != nil {
|
||||
tlsCfg.NextProtos = []string{"h2", "http/1.1"}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, addr := range cfg.DNS.DoH.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("bind doh %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
actual := ln.Addr().String()
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
// No WriteTimeout: a long-lived h2 stream should not be cut off,
|
||||
// and ReadHeaderTimeout plus IdleTimeout already bound the damage.
|
||||
}
|
||||
closeFn := ln.Close
|
||||
if tlsCfg != nil {
|
||||
srv.TLSConfig = tlsCfg
|
||||
ln = tls.NewListener(ln, tlsCfg)
|
||||
closeFn = ln.Close
|
||||
}
|
||||
s.add(listener{close: closeFn, info: ListenerInfo{
|
||||
Transport: "doh", Addr: actual, Enabled: true, Running: true,
|
||||
}})
|
||||
if tlsErr != nil {
|
||||
s.log.Warn("doh: running without TLS", "error", tlsErr)
|
||||
}
|
||||
s.log.Info("dns listener up", "transport", "doh", "addr", actual, "path", base)
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Debug("doh server stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}()
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
const contentTypeDNS = "application/dns-message"
|
||||
|
||||
// maxGETResponse bounds a DoH GET response so no intermediary truncates it.
|
||||
const maxGETResponse = 65535
|
||||
|
||||
// doHHandler builds the RFC 8484 handler.
|
||||
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var (
|
||||
payload []byte
|
||||
err error
|
||||
)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// RFC 8484 §4.1 defines exactly one parameter, "dns", holding
|
||||
// unpadded base64url. An unknown parameter is a 400 rather than a
|
||||
// 404 so a client can tell its own mistake from a wrong path.
|
||||
q := r.URL.Query()
|
||||
if !q.Has("dns") {
|
||||
writeDoHError(w, http.StatusBadRequest, "missing dns parameter")
|
||||
return
|
||||
}
|
||||
if len(q) > 1 {
|
||||
for k := range q {
|
||||
if k != "dns" {
|
||||
writeDoHError(w, http.StatusBadRequest, "unsupported parameter "+k)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err = decodeBase64URL(q.Get("dns"))
|
||||
if err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
|
||||
return
|
||||
}
|
||||
case http.MethodPost:
|
||||
if !allowPost {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "POST not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != contentTypeDNS {
|
||||
writeDoHError(w, http.StatusUnsupportedMediaType, "content-type must be "+contentTypeDNS)
|
||||
return
|
||||
}
|
||||
payload, err = readCapped(r, dns.MaxMsgSize)
|
||||
if err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "cannot read body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m := new(dns.Msg)
|
||||
if err := m.Unpack(payload); err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "malformed DNS message")
|
||||
return
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
writeDoHError(w, http.StatusBadRequest, "no question")
|
||||
return
|
||||
}
|
||||
req := s.request(r.Context(), m, httpRemoteAddr(r), "doh")
|
||||
req.TCP = true
|
||||
req.DoHPath = r.URL.Path
|
||||
res := s.eng.Handle(r.Context(), req)
|
||||
s.bump("doh", "queries")
|
||||
s.classify("doh", res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
// A dropped query still needs a body: unlike UDP, this is a
|
||||
// well-formed HTTP exchange, so hanging up would read as a
|
||||
// transport error rather than a policy decision.
|
||||
res.Msg = new(dns.Msg)
|
||||
res.Msg.SetRcode(m, dns.RcodeSuccess)
|
||||
}
|
||||
wire, err := res.Msg.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, "pack failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentTypeDNS)
|
||||
w.Header().Set("Cache-Control", "max-age=0")
|
||||
if !cfgHideIdentity(s) {
|
||||
w.Header().Set("Server", "fancy-dns/"+s.version)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(wire)
|
||||
})
|
||||
}
|
||||
|
||||
func cfgHideIdentity(s *Server) bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Get().DNS.HideIdentity
|
||||
}
|
||||
|
||||
func writeDoHError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
|
||||
// tlsConfig loads a certificate for the encrypted listeners. It returns
|
||||
// (nil, nil) when no certificate is configured, which means "run plaintext".
|
||||
func tlsConfig(certFile, keyFile, clientCA, minVersion string) (*tls.Config, error) {
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load certificate: %w", err)
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: parseTLSVersion(minVersion),
|
||||
}
|
||||
if clientCA != "" {
|
||||
pem, err := os.ReadFile(clientCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read client CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("client CA: no certificates found")
|
||||
}
|
||||
cfg.ClientCAs = pool
|
||||
cfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseTLSVersion(s string) uint16 {
|
||||
switch s {
|
||||
case "1.3":
|
||||
return tls.VersionTLS13
|
||||
case "1.0":
|
||||
return tls.VersionTLS10
|
||||
default:
|
||||
return tls.VersionTLS12
|
||||
}
|
||||
}
|
||||
|
||||
func normaliseBase(p string) string {
|
||||
if p == "" {
|
||||
return "/dns-query"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
for len(p) > 1 && p[len(p)-1] == '/' {
|
||||
p = p[:len(p)-1]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func readCapped(r *http.Request, max int64) ([]byte, error) {
|
||||
if r.ContentLength > max {
|
||||
return nil, fmt.Errorf("body too large: %d", r.ContentLength)
|
||||
}
|
||||
// LimitReader plus one extra byte is how you detect "too large" without
|
||||
// trusting Content-Length, which a client may lie about.
|
||||
body := io.LimitReader(r.Body, max+1)
|
||||
buf, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(buf)) > max {
|
||||
return nil, errors.New("body too large")
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func decodeBase64URL(s string) ([]byte, error) {
|
||||
// RFC 8484 requires unpadded base64url, but every major client sends
|
||||
// padding, so accept both rather than fail a legitimate request.
|
||||
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// httpRemoteAddr turns an http.Request's RemoteAddr into a net.Addr so the
|
||||
// shared request builder can be used. Over plain HTTP this is the socket peer;
|
||||
// behind a reverse proxy it is the proxy, which is why the caller must trust
|
||||
// X-Forwarded-For before that matters (handled in the API layer).
|
||||
func httpRemoteAddr(r *http.Request) net.Addr {
|
||||
if r.RemoteAddr == "" {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
return net.UDPAddrFromAddrPort(ap)
|
||||
}
|
||||
|
||||
// addrToAddr extracts a netip.Addr from a net.Addr.
|
||||
func addrToAddr(a net.Addr) netip.Addr {
|
||||
switch v := a.(type) {
|
||||
case *net.UDPAddr:
|
||||
return v.AddrPort().Addr().Unmap()
|
||||
case *net.TCPAddr:
|
||||
return v.AddrPort().Addr().Unmap()
|
||||
default:
|
||||
ap, err := netip.ParseAddrPort(a.String())
|
||||
if err == nil {
|
||||
return ap.Addr().Unmap()
|
||||
}
|
||||
if ip, err := netip.ParseAddr(a.String()); err == nil {
|
||||
return ip.Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats aggregates the per-transport counters plus the engine's own view.
|
||||
func (s *Server) Stats() HandlerStats {
|
||||
var out HandlerStats
|
||||
s.counters.Range(func(k, v any) bool {
|
||||
key, _ := k.(string)
|
||||
n := v.(*atomic.Uint64).Load()
|
||||
dot := strings.IndexByte(key, '.')
|
||||
if dot < 0 {
|
||||
return true
|
||||
}
|
||||
switch key[dot+1:] {
|
||||
case "queries":
|
||||
out.Queries += n
|
||||
case "blocked":
|
||||
out.Blocked += n
|
||||
case "cached":
|
||||
out.Cached += n
|
||||
case "errors":
|
||||
out.Errors += n
|
||||
case "dropped":
|
||||
out.Dropped += n
|
||||
}
|
||||
return true
|
||||
})
|
||||
if s.eng != nil {
|
||||
out.Inflight = s.eng.Inflight()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) bump(transport, field string) {
|
||||
if transport == "" {
|
||||
transport = "udp"
|
||||
}
|
||||
key := transport + "." + field
|
||||
v, _ := s.counters.LoadOrStore(key, new(atomic.Uint64))
|
||||
v.(*atomic.Uint64).Add(1)
|
||||
}
|
||||
|
||||
// flusher is implemented by the engine: it pushes buffered cache and log state
|
||||
// to disk on demand (used by the admin API's "flush" action).
|
||||
type flusher interface{ Flush() }
|
||||
|
||||
// Flush pushes buffered state to disk.
|
||||
func (s *Server) Flush() {
|
||||
if f, ok := s.eng.(flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
Build artifacts land here: run `make web` (npm ci && npm run build) then rebuild the binary.
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>fancy-dns</title></head>
|
||||
<body style="font:16px system-ui;padding:2rem"><h1>fancy-dns</h1>
|
||||
<p>Web console assets are not built in this binary. Run <code>make web</code> and rebuild.</p></body></html>
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package web serves the embedded admin console as a single-page app.
|
||||
//
|
||||
// The assets are compiled into the binary, so a deployment is one file plus a
|
||||
// config; there is no node process in production and no CDN dependency. When
|
||||
// the assets are absent (a `go build` without `npm run build`) the server says
|
||||
// so plainly instead of returning a confusing 404.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// Available reports whether compiled assets are present in this binary.
|
||||
func Available() bool {
|
||||
_, err := dist.Open("dist/index.html")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Handler serves the console, falling back to index.html for client-side
|
||||
// routes so a deep link like /logs works on a hard refresh.
|
||||
func Handler(basePath string) (http.Handler, error) {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: open assets: %w", err)
|
||||
}
|
||||
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(missingAssets))
|
||||
}), nil
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
prefix := "/"
|
||||
if basePath != "" && basePath != "/" {
|
||||
prefix = "/" + strings.Trim(basePath, "/") + "/"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Hashed asset filenames are immutable, so they can be cached hard.
|
||||
if strings.HasPrefix(r.URL.Path, prefix+"assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
clean := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(prefix, "/"))
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
if clean == "" {
|
||||
clean = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(sub, clean); err != nil {
|
||||
// Unknown path: hand it to the SPA router.
|
||||
b, rerr := fs.ReadFile(sub, "index.html")
|
||||
if rerr != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
const missingAssets = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>fancy-dns</title>
|
||||
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#0f172a;background:#f8fafc}
|
||||
code{background:#e2e8f0;padding:.15em .4em;border-radius:.25rem}</style></head>
|
||||
<body><h1>fancy-dns</h1>
|
||||
<p>The admin API is running, but the web console was not built into this binary.</p>
|
||||
<p>Build it with <code>make web</code> (or <code>cd web && npm ci && npm run build</code>), then rebuild the binary.</p>
|
||||
<p>The JSON API is available under <code>/api</code>.</p></body></html>`
|
||||
|
||||
// Version is the asset build stamp, exposed in the UI footer.
|
||||
func Version() string {
|
||||
b, err := dist.ReadFile("dist/.build-version")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// CacheControlMaxAge is the immutable-asset max age in seconds.
|
||||
const CacheControlMaxAge = 31536000
|
||||
|
||||
// maxAgeHeader builds the Cache-Control value for a hashed asset.
|
||||
func maxAgeHeader(seconds int) string {
|
||||
return "public, max-age=" + strconv.Itoa(seconds) + ", immutable"
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
// Package zonefile parses RFC 1035 master files ("zone files") with the
|
||||
// $TTL/$ORIGIN directives, parenthesised continuations and RFC 1982/1996
|
||||
// serial arithmetic.
|
||||
//
|
||||
// fancy-dns uses master files for two things: locally defined authoritative
|
||||
// data, and RPZ-style response-policy zones that double as a blocklist source.
|
||||
// Sharing one parser keeps operator expectations ("it is just a zone file")
|
||||
// true for both.
|
||||
package zonefile
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Record is one parsed resource record with its origin resolved.
|
||||
type Record struct {
|
||||
// Name is the fully-qualified, lowercased owner name.
|
||||
Name string
|
||||
TTL uint32
|
||||
RR dns.RR
|
||||
// Line is the 1-based source line, for error reporting.
|
||||
Line int
|
||||
// File is the source path, for error reporting.
|
||||
File string
|
||||
}
|
||||
|
||||
// Text renders the record back into zone-file syntax.
|
||||
func (r Record) Text() string { return r.RR.String() }
|
||||
|
||||
// Zone is the result of parsing a master file.
|
||||
type Zone struct {
|
||||
// Origin is the zone's apex as configured.
|
||||
Origin string
|
||||
// Records are in file order.
|
||||
Records []Record
|
||||
// File is the path parsed, or "" for reader input.
|
||||
File string
|
||||
// MinTTL is the smallest explicit TTL seen.
|
||||
MinTTL uint32
|
||||
// SOA holds the zone's start-of-authority record, if present.
|
||||
SOA *dns.SOA
|
||||
// DefaultTTL is the $TTL directive value, else the SOA minimum.
|
||||
DefaultTTL uint32
|
||||
}
|
||||
|
||||
// Options tunes parsing.
|
||||
type Options struct {
|
||||
// Origin overrides $ORIGIN and is the default when a file has none.
|
||||
Origin string
|
||||
// File is used in error messages.
|
||||
File string
|
||||
// MaxIncludeDepth bounds $INCLUDE recursion; 0 disables $INCLUDE.
|
||||
MaxIncludeDepth int
|
||||
// BaseDir is where relative $INCLUDE paths resolve; empty means the
|
||||
// directory of the primary file.
|
||||
BaseDir string
|
||||
// DefaultTTL is the TTL applied to records that carry none, when the
|
||||
// file has neither $TTL nor an SOA.
|
||||
DefaultTTL uint32
|
||||
}
|
||||
|
||||
// Parse reads a master file from r.
|
||||
func Parse(r io.Reader, opt Options) (*Zone, error) {
|
||||
origin := ensureDot(opt.Origin)
|
||||
if origin == "" {
|
||||
origin = "."
|
||||
}
|
||||
if opt.BaseDir == "" && opt.File != "" {
|
||||
opt.BaseDir = dirOf(opt.File)
|
||||
}
|
||||
z := &Zone{Origin: origin, File: opt.File, DefaultTTL: opt.DefaultTTL}
|
||||
|
||||
var lastOwner string
|
||||
lineno := 0
|
||||
lines := bufio.NewScanner(r)
|
||||
lines.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
|
||||
for lines.Scan() {
|
||||
startLine := lineno + 1
|
||||
lineno++
|
||||
stripped := strings.TrimSpace(stripComment(lines.Text()))
|
||||
if stripped == "" {
|
||||
continue
|
||||
}
|
||||
// Fold parenthesised continuations into one logical line.
|
||||
logical, err := fold(lines, stripped, &lineno, opt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
||||
}
|
||||
if strings.HasPrefix(logical, "$") {
|
||||
if err := z.directive(logical, opt); err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
||||
}
|
||||
lastOwner = z.Origin
|
||||
continue
|
||||
}
|
||||
assembled, err := z.assemble(logical, lastOwner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
||||
}
|
||||
rr, err := dns.NewRR(assembled)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
|
||||
}
|
||||
rec := Record{
|
||||
Name: strings.ToLower(ensureDot(rr.Header().Name)),
|
||||
TTL: rr.Header().Ttl,
|
||||
RR: rr,
|
||||
Line: startLine,
|
||||
File: opt.File,
|
||||
}
|
||||
if rec.TTL == 0 {
|
||||
rec.TTL = z.DefaultTTL
|
||||
}
|
||||
if rec.TTL > 0 && (z.MinTTL == 0 || rec.TTL < z.MinTTL) {
|
||||
z.MinTTL = rec.TTL
|
||||
}
|
||||
if soa, ok := rr.(*dns.SOA); ok {
|
||||
z.SOA = cloneSOA(soa)
|
||||
if rec.TTL > 0 {
|
||||
z.DefaultTTL = rec.TTL
|
||||
} else if z.DefaultTTL == 0 {
|
||||
z.DefaultTTL = soa.Minttl
|
||||
}
|
||||
}
|
||||
z.Records = append(z.Records, rec)
|
||||
lastOwner = rec.Name
|
||||
}
|
||||
if err := lines.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: read: %w", opt.File, err)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// fold joins a line with any parenthesised continuation lines still pending on
|
||||
// the scanner. open reports how many continuations are still outstanding after
|
||||
// the returned line, so a caller can loop until it reaches zero.
|
||||
func fold(lines *bufio.Scanner, first string, lineno *int, opt Options) (string, error) {
|
||||
depth := strings.Count(first, "(") - strings.Count(first, ")")
|
||||
var sb strings.Builder
|
||||
sb.WriteString(deparen(first))
|
||||
for depth > 0 {
|
||||
if !lines.Scan() {
|
||||
return "", fmt.Errorf("unterminated parenthesis")
|
||||
}
|
||||
*lineno++
|
||||
next := strings.TrimSpace(stripComment(lines.Text()))
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
depth += strings.Count(next, "(") - strings.Count(next, ")")
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(deparen(next))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
|
||||
// deparen removes continuation markers, leaving a single-space token stream.
|
||||
func deparen(s string) string {
|
||||
s = strings.ReplaceAll(s, "(", " ")
|
||||
s = strings.ReplaceAll(s, ")", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// assemble fills in the omitted owner name, TTL and class so that the result
|
||||
// is a complete RR line that dns.NewRR will accept.
|
||||
func (z *Zone) assemble(line, lastOwner string) (string, error) {
|
||||
fields := splitFields(line)
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("empty record")
|
||||
}
|
||||
var head string
|
||||
var rest []string
|
||||
if isRRTYPE(fields[0]) {
|
||||
// Owner omitted: it repeats the previous record's owner.
|
||||
head = lastOwner
|
||||
rest = fields
|
||||
} else {
|
||||
head = fields[0]
|
||||
rest = fields[1:]
|
||||
}
|
||||
if head == "" {
|
||||
return "", fmt.Errorf("record has no owner name and no previous owner to inherit")
|
||||
}
|
||||
head = relative(head, z.Origin)
|
||||
|
||||
out := []string{head}
|
||||
// Consume optional TTL and class in any order, then the type.
|
||||
for len(rest) > 0 && !isRRTYPE(rest[0]) {
|
||||
switch {
|
||||
case isTTLToken(rest[0]):
|
||||
out = append(out, rest[0])
|
||||
case isClassToken(rest[0]):
|
||||
out = append(out, strings.ToUpper(rest[0]))
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected token %q before the record type", rest[0])
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return "", fmt.Errorf("record %q has no type", head)
|
||||
}
|
||||
out = append(out, rest...)
|
||||
return strings.Join(out, " "), nil
|
||||
}
|
||||
|
||||
// directive handles $TTL, $ORIGIN and $INCLUDE.
|
||||
func (z *Zone) directive(line string, opt Options) error {
|
||||
fields := strings.Fields(line)
|
||||
switch strings.ToUpper(fields[0]) {
|
||||
case "$TTL":
|
||||
if len(fields) < 2 {
|
||||
return fmt.Errorf("$TTL needs a value")
|
||||
}
|
||||
ttl, err := ParseTTL(fields[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z.DefaultTTL = ttl
|
||||
case "$ORIGIN":
|
||||
if len(fields) < 2 {
|
||||
return fmt.Errorf("$ORIGIN needs a name")
|
||||
}
|
||||
z.Origin = ensureDot(fields[1])
|
||||
case "$INCLUDE":
|
||||
if len(fields) < 2 {
|
||||
return fmt.Errorf("$INCLUDE needs a file name")
|
||||
}
|
||||
if opt.MaxIncludeDepth <= 0 {
|
||||
return fmt.Errorf("$INCLUDE %q is disabled in this context", fields[1])
|
||||
}
|
||||
return fmt.Errorf("$INCLUDE %q is not supported; use blocklist.local_zones or a list source instead", fields[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rrTypes is the set of record types the assembler recognises as a type
|
||||
// token; anything absent is treated as an owner name.
|
||||
var rrTypes = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range []uint16{
|
||||
dns.TypeA, dns.TypeAAAA, dns.TypeNS, dns.TypeCNAME, dns.TypeSOA, dns.TypePTR,
|
||||
dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeDS, dns.TypeDNSKEY,
|
||||
dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM, dns.TypeRRSIG, dns.TypeNSEC,
|
||||
dns.TypeNAPTR, dns.TypeTLSA, dns.TypeSSHFP, dns.TypeSVCB, dns.TypeHTTPS,
|
||||
dns.TypeHINFO, dns.TypeLOC, dns.TypeSPF, dns.TypeAXFR,
|
||||
dns.TypeIXFR, dns.TypeANY, dns.TypeURI, dns.TypeDHCID, dns.TypeSMIMEA,
|
||||
} {
|
||||
rrTypes[dns.TypeToString[t]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// cloneSOA copies an SOA because dns.SOA.Copy is unexported.
|
||||
func cloneSOA(in *dns.SOA) *dns.SOA {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in // value copy is already deep: Ns/Mbox are plain strings
|
||||
return &out
|
||||
}
|
||||
|
||||
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
|
||||
|
||||
func isTTLToken(s string) bool {
|
||||
if s == "" || s[0] < '0' || s[0] > '9' {
|
||||
return false
|
||||
}
|
||||
if _, err := ParseTTL(s); err != nil {
|
||||
return false
|
||||
}
|
||||
return !isClassToken(s)
|
||||
}
|
||||
|
||||
func isClassToken(s string) bool {
|
||||
switch strings.ToUpper(s) {
|
||||
case "IN", "CH", "HS":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func where(file string) string {
|
||||
if file == "" {
|
||||
return "zone"
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func dirOf(p string) string {
|
||||
i := strings.LastIndexByte(p, '/')
|
||||
if i < 0 {
|
||||
return "."
|
||||
}
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
func ensureDot(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(s, ".") {
|
||||
return s
|
||||
}
|
||||
return s + "."
|
||||
}
|
||||
|
||||
// relative expands a possibly-relative name against origin.
|
||||
func relative(name, origin string) string {
|
||||
if name == "@" {
|
||||
return ensureDot(origin)
|
||||
}
|
||||
// A name that already ends in a dot is absolute, so it never gets the
|
||||
// origin appended. This is the common case in RPZ-style policy files,
|
||||
// where owners are written fully qualified.
|
||||
if strings.HasSuffix(name, ".") {
|
||||
return name
|
||||
}
|
||||
if origin == "" || origin == "." {
|
||||
return ensureDot(name)
|
||||
}
|
||||
return ensureDot(name) + origin
|
||||
}
|
||||
|
||||
func stripComment(s string) string {
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '\\':
|
||||
i++
|
||||
case '"':
|
||||
inQuote = !inQuote
|
||||
case ';':
|
||||
if !inQuote {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// splitFields splits on whitespace while keeping quoted strings intact (TXT
|
||||
// payloads are handed to dns.NewRR untouched).
|
||||
func splitFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inQuote := false
|
||||
started := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '\\' && i+1 < len(s):
|
||||
cur.WriteByte(c)
|
||||
i++
|
||||
cur.WriteByte(s[i])
|
||||
started = true
|
||||
case c == '"':
|
||||
inQuote = !inQuote
|
||||
cur.WriteByte(c)
|
||||
started = true
|
||||
case (c == ' ' || c == '\t') && !inQuote:
|
||||
if started {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
started = false
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
if started {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseTTL parses the RFC 1035 TTL grammar (unit suffixes s/m/h/d/w) and a
|
||||
// bare number of seconds.
|
||||
func ParseTTL(s string) (uint32, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty TTL")
|
||||
}
|
||||
var total uint64
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
start := i
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
return 0, fmt.Errorf("bad TTL %q", s)
|
||||
}
|
||||
n, err := strconv.ParseUint(s[start:i], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad TTL %q", s)
|
||||
}
|
||||
unitStart := i
|
||||
for i < len(s) && (s[i] < '0' || s[i] > '9') {
|
||||
i++
|
||||
}
|
||||
var mult uint64
|
||||
switch strings.ToLower(s[unitStart:i]) {
|
||||
case "", "s", "sec", "secs", "second", "seconds":
|
||||
mult = 1
|
||||
case "m", "min", "mins", "minute", "minutes":
|
||||
mult = 60
|
||||
case "h", "hour", "hours":
|
||||
mult = 3600
|
||||
case "d", "day", "days":
|
||||
mult = 86400
|
||||
case "w", "week", "weeks":
|
||||
mult = 604800
|
||||
default:
|
||||
return 0, fmt.Errorf("bad TTL unit in %q", s)
|
||||
}
|
||||
total += n * mult
|
||||
if total > 0xFFFFFFFF {
|
||||
return 0, fmt.Errorf("TTL %q overflows", s)
|
||||
}
|
||||
}
|
||||
return uint32(total), nil
|
||||
}
|
||||
|
||||
// FormatTTL renders a TTL in the compact unit form operators expect.
|
||||
func FormatTTL(ttl uint32) string {
|
||||
switch {
|
||||
case ttl >= 604800 && ttl%604800 == 0:
|
||||
return strconv.FormatUint(uint64(ttl/604800), 10) + "w"
|
||||
case ttl >= 86400 && ttl%86400 == 0:
|
||||
return strconv.FormatUint(uint64(ttl/86400), 10) + "d"
|
||||
case ttl >= 3600 && ttl%3600 == 0:
|
||||
return strconv.FormatUint(uint64(ttl/3600), 10) + "h"
|
||||
case ttl >= 60 && ttl%60 == 0:
|
||||
return strconv.FormatUint(uint64(ttl/60), 10) + "m"
|
||||
default:
|
||||
return strconv.FormatUint(uint64(ttl), 10)
|
||||
}
|
||||
}
|
||||
|
||||
// SerialNext applies RFC 1982 serial arithmetic: "+N"/"-N" or a plain number.
|
||||
func SerialNext(current uint32, spec string) (uint32, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
switch {
|
||||
case spec == "":
|
||||
return 0, fmt.Errorf("empty serial")
|
||||
case spec[0] == '+':
|
||||
n, err := strconv.ParseUint(spec[1:], 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad serial %q", spec)
|
||||
}
|
||||
return addSerial(current, uint32(n)), nil
|
||||
case spec[0] == '-':
|
||||
n, err := strconv.ParseUint(spec[1:], 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad serial %q", spec)
|
||||
}
|
||||
return addSerial(current, -uint32(n)), nil
|
||||
default:
|
||||
n, err := strconv.ParseUint(spec, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad serial %q", spec)
|
||||
}
|
||||
return uint32(n), nil
|
||||
}
|
||||
}
|
||||
|
||||
func addSerial(a, b uint32) uint32 {
|
||||
sum := (uint64(a) + uint64(b)) & 0xFFFFFFFF
|
||||
// Both operands in the "high" half means the sum stays inside the epoch,
|
||||
// so no wrap correction applies (RFC 1982 §3.2).
|
||||
if a >= 0x80000000 && b >= 0x80000000 {
|
||||
return uint32(sum)
|
||||
}
|
||||
if sum >= 0x100000000 {
|
||||
return uint32(sum - 0x100000000)
|
||||
}
|
||||
return uint32(sum)
|
||||
}
|
||||
|
||||
// ParseFile reads and parses a master file from disk.
|
||||
func ParseFile(path string, origin string) (*Zone, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return Parse(f, Options{Origin: origin, File: path, BaseDir: dirOf(path)})
|
||||
}
|
||||
|
||||
// ParseFiles parses every file in paths and merges the records into one zone.
|
||||
// Origins default to the file's base name, which is what a directory of
|
||||
// per-list policy zones usually wants.
|
||||
func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
|
||||
z := &Zone{Origin: "."}
|
||||
for _, p := range paths {
|
||||
origin := originFor(p)
|
||||
sub, err := ParseFile(p, origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z.Records = append(z.Records, sub.Records...)
|
||||
if sub.DefaultTTL > 0 {
|
||||
z.DefaultTTL = sub.DefaultTTL
|
||||
}
|
||||
if sub.SOA != nil {
|
||||
z.SOA = sub.SOA
|
||||
}
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
|
||||
// "ads.zone" both become "ads.zone.".
|
||||
func OriginFromPath(p string) string {
|
||||
base := p
|
||||
if i := strings.LastIndexByte(base, '/'); i >= 0 {
|
||||
base = base[i+1:]
|
||||
}
|
||||
if i := strings.IndexByte(base, '.'); i > 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
return ensureDot(base)
|
||||
}
|
||||
Binary file not shown.
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2488
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
+184
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Database,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { logout } from '../../lib/api.ts'
|
||||
import type { Health } from '../../lib/types.ts'
|
||||
import { HealthPill } from './HealthPill.tsx'
|
||||
import { ThemeToggle } from './ThemeToggle.tsx'
|
||||
import { Tooltip } from '../ui/Tooltip.tsx'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/upstreams', label: 'Upstreams', icon: Network },
|
||||
{ to: '/logs', label: 'Logs', icon: ScrollText },
|
||||
{ to: '/cache', label: 'Cache', icon: Database },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023px)'
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
health,
|
||||
nodeId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
health?: Health
|
||||
nodeId?: string
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(MOBILE_QUERY)
|
||||
const sync = (): void => {
|
||||
setIsMobile(query.matches)
|
||||
if (!query.matches) setSheetOpen(false)
|
||||
}
|
||||
sync()
|
||||
query.addEventListener('change', sync)
|
||||
return () => query.removeEventListener('change', sync)
|
||||
}, [])
|
||||
|
||||
// Any navigation closes the mobile sheet.
|
||||
useEffect(() => {
|
||||
setSheetOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetOpen) return
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') setSheetOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = previous
|
||||
}
|
||||
}, [sheetOpen])
|
||||
|
||||
const onSignOut = useCallback(() => {
|
||||
setSigningOut(true)
|
||||
logout()
|
||||
.catch(() => {
|
||||
/* the token is cleared either way */
|
||||
})
|
||||
.finally(() => {
|
||||
setSigningOut(false)
|
||||
navigate('/auth/login', { replace: true })
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const rail = !isMobile && collapsed
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-canvas">
|
||||
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
|
||||
the hamburger that opens the sheet instead. */}
|
||||
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
|
||||
{isMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<Menu size={18} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<PanelLeftClose
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className={cn('transition-transform', collapsed && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
|
||||
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{nodeId !== undefined && nodeId !== '' && (
|
||||
<span
|
||||
title={`Node ${nodeId}`}
|
||||
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
|
||||
>
|
||||
{nodeId}
|
||||
</span>
|
||||
)}
|
||||
{health !== undefined && <HealthPill health={health} />}
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
disabled={signingOut}
|
||||
onClick={onSignOut}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Desktop sidebar. */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
|
||||
rail ? 'w-16' : 'w-56',
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
rail && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
title={rail ? item.label : undefined}
|
||||
>
|
||||
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
{!rail && <span className="truncate">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile sheet. */}
|
||||
{sheetOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className="absolute inset-0 bg-black/60"
|
||||
/>
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between border-b border-line px-3">
|
||||
<span className="text-sm font-semibold text-fg">fancy-dns</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<X size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'pt-14 transition-[padding] duration-200',
|
||||
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-exported so pages can label collapsed rail items without importing ui. */
|
||||
export { Tooltip }
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Health } from '../../lib/types.ts'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const STYLES: Record<Health, { dot: string; text: string; ring: string }> = {
|
||||
ok: { dot: 'bg-ok', text: 'text-ok', ring: 'border-ok/30 bg-ok-soft' },
|
||||
degraded: { dot: 'bg-warn', text: 'text-warn', ring: 'border-warn/30 bg-warn-soft' },
|
||||
down: { dot: 'bg-danger', text: 'text-danger', ring: 'border-danger/30 bg-danger-soft' },
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL: Record<Health, string> = {
|
||||
ok: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
down: 'Down',
|
||||
}
|
||||
|
||||
export function HealthPill({ health, label, className }: { health: Health; label?: string; className?: string }) {
|
||||
const style = STYLES[health] ?? STYLES.degraded
|
||||
return (
|
||||
<span
|
||||
title={`Node health: ${health}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium whitespace-nowrap',
|
||||
style.ring,
|
||||
style.text,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', style.dot, health === 'down' && 'animate-pulse-soft')} />
|
||||
{label ?? DEFAULT_LABEL[health] ?? health}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function LoadingBlock({ rows = 5, className }: { rows?: number; className?: string }) {
|
||||
const count = Math.max(1, Math.min(rows, 50))
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-2', className)} aria-busy="true" aria-live="polite">
|
||||
<span className="sr-only">Loading…</span>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function PageError({
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: {
|
||||
error: string
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-3 rounded-xl border border-danger/30 bg-danger-soft p-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<AlertTriangle size={18} strokeWidth={2} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">Could not load this view</p>
|
||||
<p className="mt-0.5 text-sm break-words text-fg-muted">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button variant="outline" icon={RefreshCw} onClick={onRetry} className="shrink-0">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<header className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-fg">{title}</h1>
|
||||
{description !== undefined && (
|
||||
<p className="mt-0.5 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions !== undefined && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
const STORAGE_KEY = 'fancydns.theme'
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
try {
|
||||
const value = localStorage.getItem(STORAGE_KEY)
|
||||
if (value === 'light' || value === 'dark') return value
|
||||
} catch {
|
||||
/* storage disabled: fall through to the default */
|
||||
}
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs at module evaluation, i.e. while the bundle is still being parsed and
|
||||
* before the first paint, so the console never flashes the wrong theme.
|
||||
*/
|
||||
const INITIAL_THEME: Theme = (() => {
|
||||
if (typeof document === 'undefined') return 'dark'
|
||||
const theme = readStoredTheme()
|
||||
document.documentElement.dataset.theme = theme
|
||||
return theme
|
||||
})()
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const [theme, setTheme] = useState<Theme>(INITIAL_THEME)
|
||||
|
||||
// A second tab changing the theme should not desync this one.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent): void => {
|
||||
if (event.key !== STORAGE_KEY) return
|
||||
const next = readStoredTheme()
|
||||
document.documentElement.dataset.theme = next
|
||||
setTheme(next)
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const apply = (next: Theme): void => {
|
||||
document.documentElement.dataset.theme = next
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
/* not fatal: the choice just does not survive a reload */
|
||||
}
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(next)}
|
||||
title={`Switch to ${next} theme`}
|
||||
aria-label={`Switch to ${next} theme`}
|
||||
className={cn(
|
||||
'inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
) : (
|
||||
<Sun size={16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type BadgeTone = 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted'
|
||||
|
||||
export const BADGE_TONES: Record<BadgeTone, string> = {
|
||||
default: 'border-line bg-surface-3 text-fg',
|
||||
ok: 'border-transparent bg-ok-soft text-ok',
|
||||
warn: 'border-transparent bg-warn-soft text-warn',
|
||||
danger: 'border-transparent bg-danger-soft text-danger',
|
||||
info: 'border-transparent bg-info-soft text-info',
|
||||
muted: 'border-transparent bg-surface-3 text-fg-subtle',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = 'default',
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
tone?: BadgeTone
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs font-medium whitespace-nowrap',
|
||||
BADGE_TONES[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Spinner } from './Spinner.tsx'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
|
||||
|
||||
export const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-accent-strong text-accent-fg hover:brightness-110 active:brightness-95',
|
||||
secondary: 'bg-surface-3 text-fg hover:bg-line-strong border border-line',
|
||||
ghost: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
|
||||
danger: 'bg-danger text-white hover:brightness-110 active:brightness-95',
|
||||
outline: 'border border-line-strong text-fg hover:bg-surface-3',
|
||||
}
|
||||
|
||||
export const BUTTON_SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-8 gap-1.5 px-2.5 text-xs',
|
||||
md: 'h-9 gap-2 px-3 text-sm',
|
||||
lg: 'h-11 gap-2 px-5 text-sm',
|
||||
icon: 'size-9 justify-center',
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
icon?: LucideIcon
|
||||
iconRight?: LucideIcon
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon: Icon,
|
||||
iconRight: IconRight,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
// A pending action must not be fireable twice: a double "Flush cache"
|
||||
// is a support ticket.
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-lg font-medium whitespace-nowrap transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
BUTTON_VARIANTS[variant],
|
||||
BUTTON_SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
|
||||
) : (
|
||||
Icon && <Icon size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
{children}
|
||||
{IconRight && !loading && (
|
||||
<IconRight size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
padded?: boolean
|
||||
}) {
|
||||
const hasHeader = title !== undefined || subtitle !== undefined || actions !== undefined
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-panel',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<CardHeader actions={actions}>
|
||||
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
|
||||
{title === undefined && subtitle !== undefined && (
|
||||
<p className="text-sm text-fg-muted">{subtitle}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardBody padded={padded}>{children}</CardBody>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface-2 px-4 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
|
||||
{children}
|
||||
</div>
|
||||
{actions !== undefined && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
subtitle,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{children}</h2>
|
||||
{subtitle !== undefined && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
padded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex-1 min-w-0', padded && 'p-4', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import { Dialog } from './Dialog.tsx'
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
destructive = false,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void | Promise<unknown>
|
||||
title: ReactNode
|
||||
message: ReactNode
|
||||
confirmLabel?: string
|
||||
destructive?: boolean
|
||||
pending?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'danger' : 'primary'}
|
||||
onClick={() => void onConfirm()}
|
||||
loading={pending}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-fg-muted">{message}</p>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
/** Copies a value (a rule, an address, a key) to the clipboard. */
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = 'Copy',
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
label?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = (): void => {
|
||||
const clipboard = navigator?.clipboard
|
||||
if (!clipboard) return
|
||||
void clipboard.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true)
|
||||
if (timer.current !== undefined) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setCopied(false), 1500)
|
||||
},
|
||||
() => {
|
||||
// A denied clipboard permission is not worth a toast; the button just
|
||||
// does not flip.
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={copied ? Check : Copy}
|
||||
label={copied ? 'Copied' : label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copy}
|
||||
className={cn(copied && 'text-ok', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
const SIZES = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Native `<dialog>`: focus trapping, inert background and Escape handling come
|
||||
* from the platform instead of a focus-trap library.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
const titleId = useId()
|
||||
const descId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (open && !el.open) {
|
||||
if (typeof el.showModal === 'function') el.showModal()
|
||||
else el.setAttribute('open', '')
|
||||
} else if (!open && el.open) {
|
||||
el.close()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The element can be removed while open (route change): close it so the
|
||||
// top layer does not keep a detached node.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
return () => {
|
||||
if (el?.open) el.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>): void => {
|
||||
// Only a click on the dialog element itself is a backdrop click; clicks
|
||||
// inside the panel bubble up with a different target.
|
||||
if (event.target === ref.current) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description !== undefined ? descId : undefined}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
onClose={onClose}
|
||||
onClick={onBackdropClick}
|
||||
className={cn(
|
||||
'm-auto w-[calc(100vw-2rem)] rounded-xl border border-line bg-surface p-0 text-fg shadow-panel backdrop:bg-black/60',
|
||||
'open:animate-slide-up',
|
||||
SIZES[size],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 id={titleId} className="text-sm font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{description !== undefined && (
|
||||
<p id={descId} className="mt-0.5 text-xs text-fg-muted">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IconButton icon={X} label="Close" onClick={onClose} />
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-auto px-4 py-4 text-sm">{children}</div>
|
||||
{footer !== undefined && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-line px-6 py-12 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="grid size-11 place-items-center rounded-full bg-surface-3 text-fg-subtle">
|
||||
<Icon size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="max-w-md">
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description !== undefined && (
|
||||
<p className="mt-1 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action !== undefined && <div className="mt-1 flex items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { BUTTON_SIZES, BUTTON_VARIANTS } from './Button.tsx'
|
||||
import type { ButtonSize, ButtonVariant } from './Button.tsx'
|
||||
import { Tooltip } from './Tooltip.tsx'
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
tooltipSide = 'top',
|
||||
className,
|
||||
type = 'button',
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} side={tooltipSide}>
|
||||
<button
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-lg transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
BUTTON_VARIANTS[variant],
|
||||
BUTTON_SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useId } from 'react'
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export const FIELD_BASE =
|
||||
'w-full rounded-lg border bg-surface-2 text-fg placeholder:text-fg-subtle transition-colors focus:outline-none focus-visible:border-accent disabled:opacity-60'
|
||||
|
||||
export function FieldFrame({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{label !== undefined && (
|
||||
<label htmlFor={id} className="text-xs font-medium text-fg-muted">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{error !== undefined && error !== null && error !== '' ? (
|
||||
<p id={`${id}-error`} role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
) : hint !== undefined ? (
|
||||
<p id={`${id}-hint`} className="text-xs text-fg-subtle">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
icon?: LucideIcon
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, icon: Icon, className, id, ...props }: InputProps) {
|
||||
const autoId = useId()
|
||||
const inputId = id ?? autoId
|
||||
const describedBy = error ? `${inputId}-error` : hint !== undefined ? `${inputId}-hint` : undefined
|
||||
|
||||
const input = (
|
||||
<div className="relative">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-fg-subtle"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'h-9 px-2.5 text-sm',
|
||||
Icon && 'pl-8',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return input
|
||||
return (
|
||||
<FieldFrame id={inputId} label={label} hint={hint} error={error}>
|
||||
{input}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Kbd({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-line-strong bg-surface-3 px-1.5 font-mono text-[10px] font-medium text-fg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
onPage,
|
||||
className,
|
||||
}: {
|
||||
page: number
|
||||
perPage: number
|
||||
total: number
|
||||
onPage: (page: number) => void
|
||||
className?: string
|
||||
}) {
|
||||
const size = perPage > 0 ? perPage : 1
|
||||
const pages = Math.max(1, Math.ceil(total / size))
|
||||
const current = Math.min(Math.max(1, page), pages)
|
||||
const first = (current - 1) * size + 1
|
||||
const last = Math.min(total, current * size)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center justify-between gap-3 text-xs text-fg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="tabular">
|
||||
{total === 0 ? 'no rows' : `${first}–${last} of ${total.toLocaleString('en-US')}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ChevronLeft}
|
||||
disabled={current <= 1}
|
||||
onClick={() => onPage(current - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="tabular px-1">
|
||||
{current} / {pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
iconRight={ChevronRight}
|
||||
disabled={current >= pages}
|
||||
onClick={() => onPage(current + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type ProgressTone = 'default' | 'ok' | 'warn' | 'danger' | 'info'
|
||||
|
||||
const TONES: Record<ProgressTone, string> = {
|
||||
default: 'bg-accent',
|
||||
ok: 'bg-ok',
|
||||
warn: 'bg-warn',
|
||||
danger: 'bg-danger',
|
||||
info: 'bg-info',
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
tone = 'default',
|
||||
className,
|
||||
showLabel = false,
|
||||
}: {
|
||||
value: number
|
||||
max?: number
|
||||
tone?: ProgressTone
|
||||
className?: string
|
||||
showLabel?: boolean
|
||||
}) {
|
||||
const safeMax = Number.isFinite(max) && max > 0 ? max : 100
|
||||
const safeValue = Number.isFinite(value) ? value : 0
|
||||
const pct = Math.max(0, Math.min(100, (safeValue / safeMax) * 100))
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full min-w-0 overflow-hidden rounded-full bg-surface-3"
|
||||
>
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-[width] duration-300', TONES[tone])}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="tabular w-9 shrink-0 text-right text-xs text-fg-muted">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function SectionTitle({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-end justify-between gap-3', className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg">{title}</h2>
|
||||
{description !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions !== undefined && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, SelectHTMLAttributes } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { FIELD_BASE, FieldFrame } from './Input.tsx'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
options: SelectOption[]
|
||||
className?: string
|
||||
selectClassName?: string
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
options,
|
||||
className,
|
||||
selectClassName,
|
||||
id,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const autoId = useId()
|
||||
const selectId = id ?? autoId
|
||||
const describedBy = error ? `${selectId}-error` : hint !== undefined ? `${selectId}-hint` : undefined
|
||||
|
||||
const select = (
|
||||
<div className="relative">
|
||||
<select
|
||||
id={selectId}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'h-9 appearance-none py-0 pr-8 pl-2.5 text-sm',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-fg-subtle"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return select
|
||||
return (
|
||||
<FieldFrame id={selectId} label={label} hint={hint} error={error}>
|
||||
{select}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
/** A shimmer placeholder used while a panel's data is in flight. */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-md bg-surface-3',
|
||||
'after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/8 after:to-transparent',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const SIZES = {
|
||||
sm: 'size-3 border',
|
||||
md: 'size-4 border-2',
|
||||
lg: 'size-6 border-2',
|
||||
} as const
|
||||
|
||||
export function Spinner({ size = 'md', className }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
'inline-block shrink-0 animate-spin rounded-full border-current border-t-transparent opacity-70',
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { Skeleton } from './Skeleton.tsx'
|
||||
|
||||
export type StatTone = 'default' | 'ok' | 'warn' | 'danger'
|
||||
|
||||
const TONE_TEXT: Record<StatTone, string> = {
|
||||
default: 'text-fg',
|
||||
ok: 'text-ok',
|
||||
warn: 'text-warn',
|
||||
danger: 'text-danger',
|
||||
}
|
||||
|
||||
const TONE_ICON: Record<StatTone, string> = {
|
||||
default: 'bg-surface-3 text-fg-muted',
|
||||
ok: 'bg-ok-soft text-ok',
|
||||
warn: 'bg-warn-soft text-warn',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon: Icon,
|
||||
trend,
|
||||
tone = 'default',
|
||||
className,
|
||||
loading,
|
||||
}: {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
icon?: LucideIcon
|
||||
trend?: number
|
||||
tone?: StatTone
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}) {
|
||||
const TrendIcon = trend === undefined || trend === 0 ? Minus : trend > 0 ? ArrowUpRight : ArrowDownRight
|
||||
// A rising block rate is good news; a rising error rate is not. The tone of
|
||||
// the card wins, otherwise fall back to "up is good".
|
||||
const trendTone = trend === undefined || trend === 0 ? 'text-fg-subtle' : (trend > 0) === (tone !== 'danger') ? 'text-ok' : 'text-danger'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-start gap-3 rounded-xl border border-line bg-surface p-4 shadow-panel',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', TONE_ICON[tone])}>
|
||||
<Icon size={18} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium tracking-wide text-fg-muted uppercase">{label}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-7 w-24" />
|
||||
) : (
|
||||
<p className={cn('tabular mt-1 truncate text-2xl font-semibold', TONE_TEXT[tone])}>{value}</p>
|
||||
)}
|
||||
{(hint !== undefined || trend !== undefined) && (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs">
|
||||
{trend !== undefined && (
|
||||
<span className={cn('tabular inline-flex items-center gap-0.5 font-medium', trendTone)}>
|
||||
<TrendIcon size={14} aria-hidden="true" />
|
||||
{Math.abs(trend).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{hint !== undefined && <span className="truncate text-fg-subtle">{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const control = (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
checked ? 'border-accent-strong bg-accent-strong' : 'border-line-strong bg-surface-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute size-3.5 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (label === undefined && description === undefined) return control
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start justify-between gap-3',
|
||||
disabled ? 'opacity-60' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
{label !== undefined && <span className="text-sm text-fg">{label}</span>}
|
||||
{description !== undefined && (
|
||||
<span className="text-xs text-fg-muted">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span onClick={(e) => e.preventDefault()} className="mt-0.5 shrink-0">
|
||||
{control}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type TableAlign = 'left' | 'right' | 'center'
|
||||
|
||||
export interface TableColumn {
|
||||
key: string
|
||||
header: ReactNode
|
||||
align?: TableAlign
|
||||
width?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ALIGN: Record<TableAlign, string> = {
|
||||
left: 'text-left',
|
||||
right: 'text-right',
|
||||
center: 'text-center',
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain data table: the caller owns the cell contents, so a cell is either a
|
||||
* ready-made node (a Badge, a switch) or a raw value it can format.
|
||||
*/
|
||||
export function Table({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
empty = 'Nothing to show',
|
||||
loading = false,
|
||||
onRowClick,
|
||||
maxHeight,
|
||||
className,
|
||||
}: {
|
||||
columns: TableColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
rowKey: (row: Record<string, unknown>) => string
|
||||
empty?: ReactNode
|
||||
loading?: boolean
|
||||
onRowClick?: (row: Record<string, unknown>) => void
|
||||
maxHeight?: number | string
|
||||
className?: string
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
|
||||
const maxHeightStyle =
|
||||
typeof maxHeight === 'number' ? { maxHeight: `${maxHeight}px` } : maxHeight ? { maxHeight } : undefined
|
||||
|
||||
if (rows.length === 0 && !loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg border border-dashed border-line p-8 text-sm text-fg-subtle',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{empty}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative overflow-auto rounded-lg border border-line', className)}
|
||||
style={maxHeightStyle}
|
||||
>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-surface-2">
|
||||
<tr className="border-b border-line">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
className={cn(
|
||||
'px-3 py-2 text-xs font-semibold tracking-wide text-fg-muted uppercase',
|
||||
ALIGN[column.align ?? 'left'],
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = rowKey(row)
|
||||
const clickable = onRowClick !== undefined
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={clickable ? () => onRowClick(row) : undefined}
|
||||
onMouseEnter={clickable ? () => setHovered(key) : undefined}
|
||||
onMouseLeave={clickable ? () => setHovered(null) : undefined}
|
||||
className={cn(
|
||||
'border-b border-line last:border-0',
|
||||
clickable && 'cursor-pointer',
|
||||
hovered === key ? 'bg-surface-3' : 'hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const value = row[column.key]
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-3 py-2 align-middle text-fg',
|
||||
ALIGN[column.align ?? 'left'],
|
||||
column.align === 'right' && 'tabular',
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{value === null || value === undefined || value === ''
|
||||
? '—'
|
||||
: isRenderable(value)
|
||||
? value
|
||||
: String(value)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-surface/60 backdrop-blur-[1px]">
|
||||
<span className="rounded-md border border-line bg-surface-2 px-2 py-1 text-xs text-fg-muted">
|
||||
Loading…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** React elements and arrays render as-is; a plain object does not. */
|
||||
function isRenderable(value: unknown): value is ReactNode {
|
||||
if (Array.isArray(value)) return true
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return typeof (value as { $$typeof?: unknown }).$$typeof === 'symbol'
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
tabs: TabItem[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-line bg-surface-2 p-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === value
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'tabular rounded px-1 py-px text-[10px]',
|
||||
active ? 'bg-accent-soft text-accent' : 'bg-surface-3 text-fg-subtle',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
import { FIELD_BASE, FieldFrame } from './Input.tsx'
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
monospace?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
monospace = false,
|
||||
className,
|
||||
id,
|
||||
rows = 6,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const autoId = useId()
|
||||
const fieldId = id ?? autoId
|
||||
const describedBy = error ? `${fieldId}-error` : hint !== undefined ? `${fieldId}-hint` : undefined
|
||||
|
||||
const textarea = (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
rows={rows}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
FIELD_BASE,
|
||||
'resize-y px-2.5 py-2 text-sm leading-relaxed',
|
||||
monospace && 'font-mono text-xs',
|
||||
error ? 'border-danger' : 'border-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (label === undefined && hint === undefined && error === undefined) return textarea
|
||||
return (
|
||||
<FieldFrame id={fieldId} label={label} hint={hint} error={error}>
|
||||
{textarea}
|
||||
</FieldFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
export type ToastTone = 'default' | 'ok' | 'warn' | 'danger'
|
||||
|
||||
export interface ToastOptions {
|
||||
title: string
|
||||
description?: string
|
||||
tone?: ToastTone
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface ToastItem extends ToastOptions {
|
||||
id: number
|
||||
tone: ToastTone
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: ToastItem[]
|
||||
toast: (options: ToastOptions) => void
|
||||
dismiss: (id: number) => void
|
||||
claimToaster: () => boolean
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
const TONE_STYLES: Record<ToastTone, string> = {
|
||||
default: 'border-line bg-surface-3',
|
||||
ok: 'border-ok/40 bg-ok-soft',
|
||||
warn: 'border-warn/40 bg-warn-soft',
|
||||
danger: 'border-danger/40 bg-danger-soft',
|
||||
}
|
||||
|
||||
const TONE_ICON: Record<ToastTone, typeof Info> = {
|
||||
default: Info,
|
||||
ok: CheckCircle2,
|
||||
warn: AlertTriangle,
|
||||
danger: XCircle,
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
|
||||
const claimed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current
|
||||
return () => {
|
||||
for (const timer of pending.values()) clearTimeout(timer)
|
||||
pending.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
timers.current.delete(id)
|
||||
}
|
||||
setToasts((current) => current.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions) => {
|
||||
const item: ToastItem = {
|
||||
tone: 'default',
|
||||
durationMs: 5000,
|
||||
...options,
|
||||
id: nextId++,
|
||||
}
|
||||
// Cap the stack: a failing refresh loop must not bury the screen.
|
||||
setToasts((current) => [...current, item].slice(-5))
|
||||
if (item.durationMs > 0) {
|
||||
timers.current.set(item.id, setTimeout(() => dismiss(item.id), item.durationMs))
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
)
|
||||
|
||||
const claimToaster = useCallback(() => {
|
||||
if (claimed.current) return false
|
||||
claimed.current = true
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({ toasts, toast, dismiss, claimToaster }),
|
||||
[toasts, toast, dismiss, claimToaster],
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const NOOP = {
|
||||
toast: () => {},
|
||||
dismiss: () => {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a toast. Outside a provider it degrades to a no-op instead of
|
||||
* throwing: a notification is never a reason for a page to fail to render.
|
||||
*/
|
||||
export function useToast(): { toast: (options: ToastOptions) => void } {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (ctx === null) return NOOP
|
||||
return { toast: ctx.toast }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the stack bottom-right in a portal on <body>. The provider already
|
||||
* mounts one; a hand-placed extra <Toaster/> is ignored so the DOM never
|
||||
* doubles up.
|
||||
*/
|
||||
export function Toaster() {
|
||||
const ctx = useContext(ToastContext)
|
||||
const [owns] = useState(() => (ctx === null ? false : ctx.claimToaster()))
|
||||
|
||||
if (ctx === null || !owns || ctx.toasts.length === 0) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed right-4 bottom-4 z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
|
||||
>
|
||||
{ctx.toasts.map((item) => {
|
||||
const Icon = TONE_ICON[item.tone]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'animate-slide-up pointer-events-auto flex items-start gap-2.5 rounded-lg border p-3 shadow-panel',
|
||||
TONE_STYLES[item.tone],
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className="mt-0.5 shrink-0 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg">{item.title}</p>
|
||||
{item.description !== undefined && (
|
||||
<p className="mt-0.5 text-xs break-words text-fg-muted">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => ctx.dismiss(item.id)}
|
||||
className="shrink-0 rounded-md p-0.5 text-fg-subtle transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '../../lib/cn.ts'
|
||||
|
||||
const SIDES = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Hover/focus tooltip built from one wrapper element. No positioning library
|
||||
* and no portal: the console has no tooltip that needs to escape an
|
||||
* overflow container, and CSS keeps it working in a screenshot test.
|
||||
*/
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
className,
|
||||
}: {
|
||||
content: ReactNode
|
||||
children: ReactNode
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
className?: string
|
||||
}) {
|
||||
if (content === undefined || content === null || content === '') return <>{children}</>
|
||||
return (
|
||||
<span className={cn('group/tt relative inline-flex', className)}>
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-50 hidden w-max max-w-64 rounded-md border border-line bg-surface-3 px-2 py-1 text-xs font-normal text-fg shadow-panel',
|
||||
'animate-fade-in group-hover/tt:block group-focus-within/tt:block',
|
||||
SIDES[side],
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Every primitive the pages are allowed to reach for. */
|
||||
export { Card, CardBody, CardFooter, CardHeader, CardTitle } from './Card.tsx'
|
||||
export { StatCard } from './StatCard.tsx'
|
||||
export { Button } from './Button.tsx'
|
||||
export { IconButton } from './IconButton.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { Textarea } from './Textarea.tsx'
|
||||
export { Select } from './Select.tsx'
|
||||
export { Switch } from './Switch.tsx'
|
||||
export { Badge } from './Badge.tsx'
|
||||
export { Table } from './Table.tsx'
|
||||
export { Dialog } from './Dialog.tsx'
|
||||
export { ConfirmDialog } from './ConfirmDialog.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export { ToastProvider, Toaster, useToast } from './Toast.tsx'
|
||||
export { Skeleton } from './Skeleton.tsx'
|
||||
export { EmptyState } from './EmptyState.tsx'
|
||||
export { Tabs } from './Tabs.tsx'
|
||||
export { Spinner } from './Spinner.tsx'
|
||||
export { Progress } from './Progress.tsx'
|
||||
export { Pagination } from './Pagination.tsx'
|
||||
export { Kbd } from './Kbd.tsx'
|
||||
export { CopyButton } from './CopyButton.tsx'
|
||||
export { SectionTitle } from './SectionTitle.tsx'
|
||||
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx'
|
||||
export type { InputProps } from './Input.tsx'
|
||||
export type { TextareaProps } from './Textarea.tsx'
|
||||
export type { SelectOption, SelectProps } from './Select.tsx'
|
||||
export type { TabItem } from './Tabs.tsx'
|
||||
export type { TableColumn } from './Table.tsx'
|
||||
export type { ToastOptions, ToastTone } from './Toast.tsx'
|
||||
export type { BadgeTone } from './Badge.tsx'
|
||||
export type { IconButtonProps } from './IconButton.tsx'
|
||||
export type { StatTone } from './StatCard.tsx'
|
||||
export type { ProgressTone } from './Progress.tsx'
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface ApiState<T> {
|
||||
data: T | null
|
||||
error: string | null
|
||||
loading: boolean
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
function message(err: unknown): string {
|
||||
if (err instanceof Error) return err.message
|
||||
if (typeof err === 'string') return err
|
||||
return 'request failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` on mount and whenever `deps` change, aborting the previous request
|
||||
* so a fast filter change cannot let a slow earlier response win a race.
|
||||
*/
|
||||
export function useApi<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<T> {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [nonce, setNonce] = useState(0)
|
||||
|
||||
const fnRef = useRef(fn)
|
||||
fnRef.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let live = true
|
||||
setLoading(true)
|
||||
|
||||
fnRef
|
||||
.current()
|
||||
.then((value) => {
|
||||
if (!live) return
|
||||
setData(value)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!live) return
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(message(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!live) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, nonce])
|
||||
|
||||
const refetch = useCallback(() => setNonce((n) => n + 1), [])
|
||||
|
||||
return { data, error, loading, refetch }
|
||||
}
|
||||
|
||||
export interface AsyncAction<TArgs extends unknown[], T> {
|
||||
run: (...args: TArgs) => Promise<T | null>
|
||||
pending: boolean
|
||||
error: string | null
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A fire-and-forget mutation: `run` never rejects, it resolves to `null` on
|
||||
* failure and puts the message in `error` so the caller can just toast it.
|
||||
*/
|
||||
export function useAsyncAction<TArgs extends unknown[], T>(
|
||||
fn: (...args: TArgs) => Promise<T>,
|
||||
): AsyncAction<TArgs, T> {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const mounted = useRef(true)
|
||||
const fnRef = useRef(fn)
|
||||
fnRef.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const run = useCallback(async (...args: TArgs): Promise<T | null> => {
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fnRef.current(...args)
|
||||
return result
|
||||
} catch (err) {
|
||||
if (mounted.current) setError(message(err))
|
||||
return null
|
||||
} finally {
|
||||
if (mounted.current) setPending(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => setError(null), [])
|
||||
|
||||
return { run, pending, error, clearError }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Polls `fn` every `intervalMs`, but only while the tab is visible: a hidden
|
||||
* dashboard tab burning CPU on a node nobody is looking at is pure waste. The
|
||||
* first tick after the tab becomes visible is immediate, so the numbers are
|
||||
* never stale on return.
|
||||
*/
|
||||
export function useAutoRefresh(fn: () => void, intervalMs: number, enabled = true): void {
|
||||
const fnRef = useRef(fn)
|
||||
fnRef.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || intervalMs <= 0) return
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const start = (): void => {
|
||||
if (timer !== undefined) return
|
||||
if (document.hidden) {
|
||||
// Hidden: no timer at all. The visibility listener restarts it.
|
||||
return
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
if (!document.hidden) fnRef.current()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
const stop = (): void => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibility = (): void => {
|
||||
if (document.hidden) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
start()
|
||||
fnRef.current()
|
||||
}
|
||||
|
||||
start()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [intervalMs, enabled])
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Trails `value` by `ms`. Used by the search boxes so typing does not fire a
|
||||
* request per keystroke.
|
||||
*/
|
||||
export function useDebounced<T>(value: T, ms: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
if (ms <= 0) {
|
||||
setDebounced(value)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setDebounced(value), ms)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, ms])
|
||||
|
||||
return debounced
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export type TimeRange = '15m' | '1h' | '6h' | '24h'
|
||||
|
||||
const RANGE_MS: Record<TimeRange, number> = {
|
||||
'15m': 15 * 60_000,
|
||||
'1h': 60 * 60_000,
|
||||
'6h': 6 * 60 * 60_000,
|
||||
'24h': 24 * 60 * 60_000,
|
||||
}
|
||||
|
||||
/** Roughly 40-60 bars per chart, which is where recharts stays readable. */
|
||||
const RANGE_BUCKETS: Record<TimeRange, number> = {
|
||||
'15m': 30,
|
||||
'1h': 60,
|
||||
'6h': 72,
|
||||
'24h': 96,
|
||||
}
|
||||
|
||||
export const TIME_RANGES: TimeRange[] = ['15m', '1h', '6h', '24h']
|
||||
|
||||
export interface TimeRangeState {
|
||||
range: TimeRange
|
||||
setRange: (range: TimeRange) => void
|
||||
rangeMs: number
|
||||
buckets: number
|
||||
/** ISO timestamp for the `since` query parameter. */
|
||||
since: () => string
|
||||
}
|
||||
|
||||
export function useTimeRange(defaultRange: TimeRange = '1h'): TimeRangeState {
|
||||
const [range, setRange] = useState<TimeRange>(defaultRange)
|
||||
|
||||
const rangeMs = RANGE_MS[range]
|
||||
const buckets = RANGE_BUCKETS[range]
|
||||
|
||||
return {
|
||||
range,
|
||||
setRange,
|
||||
rangeMs,
|
||||
buckets,
|
||||
since: () => new Date(Date.now() - rangeMs).toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Dark-first operator console. Every colour the UI uses comes from the custom
|
||||
properties below, so a theme flip is a single attribute on <html> and never
|
||||
a re-render. Tailwind utilities read them through `@theme inline`, which
|
||||
keeps the var() reference in the generated CSS instead of freezing the
|
||||
value at build time. */
|
||||
|
||||
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces, from furthest back to closest to the reader. */
|
||||
--canvas: #0a0c10;
|
||||
--surface: #11141a;
|
||||
--surface-2: #161a21;
|
||||
--surface-3: #1d222b;
|
||||
--line: #242a34;
|
||||
--line-strong: #333b49;
|
||||
|
||||
/* Text. */
|
||||
--fg: #e7eaef;
|
||||
--fg-muted: #98a2b3;
|
||||
--fg-subtle: #6b7687;
|
||||
|
||||
/* One accent: emerald. */
|
||||
--accent: #34d399;
|
||||
--accent-strong: #10b981;
|
||||
--accent-fg: #04120c;
|
||||
--accent-soft: rgb(52 211 153 / 14%);
|
||||
--accent-ring: rgb(52 211 153 / 45%);
|
||||
|
||||
/* Semantics. */
|
||||
--ok: #34d399;
|
||||
--ok-soft: rgb(52 211 153 / 14%);
|
||||
--warn: #fbbf24;
|
||||
--warn-soft: rgb(251 191 36 / 14%);
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgb(248 113 113 / 14%);
|
||||
--info: #60a5fa;
|
||||
--info-soft: rgb(96 165 250 / 14%);
|
||||
|
||||
--shadow-panel: 0 10px 30px -12px rgb(0 0 0 / 70%);
|
||||
--scroll-thumb: #2b323d;
|
||||
--scroll-thumb-hover: #3a4351;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--canvas: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #ffffff;
|
||||
--surface-3: #eef1f5;
|
||||
--line: #e3e7ed;
|
||||
--line-strong: #cbd2dc;
|
||||
|
||||
--fg: #0f141b;
|
||||
--fg-muted: #55616f;
|
||||
--fg-subtle: #7b8794;
|
||||
|
||||
--accent: #059669;
|
||||
--accent-strong: #047857;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-soft: rgb(5 150 105 / 10%);
|
||||
--accent-ring: rgb(5 150 105 / 40%);
|
||||
|
||||
--ok: #059669;
|
||||
--ok-soft: rgb(5 150 105 / 12%);
|
||||
--warn: #b45309;
|
||||
--warn-soft: rgb(180 83 9 / 12%);
|
||||
--danger: #dc2626;
|
||||
--danger-soft: rgb(220 38 38 / 10%);
|
||||
--info: #2563eb;
|
||||
--info-soft: rgb(37 99 235 / 10%);
|
||||
|
||||
--shadow-panel: 0 10px 30px -14px rgb(15 23 42 / 25%);
|
||||
--scroll-thumb: #cbd2dc;
|
||||
--scroll-thumb-hover: #aab3c0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-3: var(--surface-3);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
|
||||
--color-fg: var(--fg);
|
||||
--color-fg-muted: var(--fg-muted);
|
||||
--color-fg-subtle: var(--fg-subtle);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-fg: var(--accent-fg);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
|
||||
--color-ok: var(--ok);
|
||||
--color-ok-soft: var(--ok-soft);
|
||||
--color-warn: var(--warn);
|
||||
--color-warn-soft: var(--warn-soft);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-info: var(--info);
|
||||
--color-info-soft: var(--info-soft);
|
||||
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
|
||||
--animate-fade-in: fade-in 180ms ease-out both;
|
||||
--animate-slide-up: slide-up 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
--animate-pulse-soft: pulse-soft 2.4s ease-in-out infinite;
|
||||
--animate-shimmer: shimmer 1.6s linear infinite;
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
background-color: var(--canvas);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--accent-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Numeric columns must not jitter as digits change. */
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scroll-thumb) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scroll-thumb);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--scroll-thumb-hover);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Typed client for the fancy-dns admin API.
|
||||
*
|
||||
* Every path is relative (`/api/...`) because the Go binary serves the built
|
||||
* assets and the API from the same origin; in dev Vite proxies `/api` to the
|
||||
* local daemon.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlocklistsResponse,
|
||||
CacheResponse,
|
||||
CacheTopResponse,
|
||||
ClientsResponse,
|
||||
ClientGroup,
|
||||
Dashboard,
|
||||
DefaultPolicy,
|
||||
DomainAction,
|
||||
DomainCheck,
|
||||
DomainFilters,
|
||||
DomainsResponse,
|
||||
LogFilters,
|
||||
LogItem,
|
||||
LoginResponse,
|
||||
LogsResponse,
|
||||
LiveStats,
|
||||
OkResponse,
|
||||
SaveSettingsResponse,
|
||||
SettingsResponse,
|
||||
UpstreamBootstrap,
|
||||
UpstreamPolicy,
|
||||
UpstreamServer,
|
||||
UpstreamsResponse,
|
||||
ValidateResponse,
|
||||
} from './types.ts'
|
||||
|
||||
const TOKEN_KEY = 'fancydns.token'
|
||||
|
||||
/** An HTTP-level failure. `body` is the raw response text for the logs. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
body: string
|
||||
|
||||
constructor(status: number, message: string, body = '') {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
} catch {
|
||||
// Private mode / disabled storage: behave as an anonymous session.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
/* non-fatal: the session just does not survive a reload */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Types the API returns for endpoints the shared types.ts does not name. */
|
||||
export interface WhoamiResponse {
|
||||
name: string
|
||||
mode: string
|
||||
read_only: boolean
|
||||
can_write: boolean
|
||||
auth_mode: string
|
||||
base_path: string
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
api_key?: string
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface RefreshBlocklistsResponse {
|
||||
started: boolean
|
||||
names: string[]
|
||||
}
|
||||
|
||||
export interface ToggleBlocklistResponse {
|
||||
ok: boolean
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AddDomainInput {
|
||||
name: string
|
||||
action: DomainAction | string
|
||||
list?: string
|
||||
source?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
/** A lookup failure comes back 200 with an `error` string, not a 4xx. */
|
||||
export type DomainCheckResponse = DomainCheck & { error?: string }
|
||||
|
||||
export interface ClientsPayload {
|
||||
groups: ClientGroup[]
|
||||
default_policy: DefaultPolicy | string
|
||||
}
|
||||
|
||||
export interface UpstreamsPayload {
|
||||
policy: UpstreamPolicy | string
|
||||
servers: UpstreamServer[]
|
||||
bootstraps: UpstreamBootstrap[]
|
||||
healthcheck_interval_s?: number
|
||||
max_attempts?: number
|
||||
breaker_threshold?: number
|
||||
}
|
||||
|
||||
export interface SaveResponse {
|
||||
ok: boolean
|
||||
version: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface FlushCacheResponse {
|
||||
ok: boolean
|
||||
entries: number
|
||||
}
|
||||
|
||||
export interface ReloadSettingsResponse {
|
||||
ok: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
export type LogStreamHandlers = {
|
||||
onEntry: (entry: LogItem) => void
|
||||
onStats: (stats: LiveStats) => void
|
||||
onReady: (info: { at?: string; version?: string }) => void
|
||||
onError: (message: string) => void
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
/** Query values the server should not see at all. */
|
||||
type ParamValue = string | number | boolean | null | undefined
|
||||
|
||||
/** Builds `/api/thing?a=1&b=2`, dropping empty values. */
|
||||
export function apiUrl(path: string, params?: Record<string, ParamValue>): string {
|
||||
if (!params) return path
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (typeof value === 'string' && value === '') continue
|
||||
search.set(key, String(value))
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
/** One redirect per session, however many requests race into a 401. */
|
||||
let redirecting = false
|
||||
|
||||
function sendToLogin(): void {
|
||||
if (redirecting) return
|
||||
redirecting = true
|
||||
const next = typeof location === 'undefined' ? '' : location.pathname
|
||||
location.assign(`/auth/login?next=${encodeURIComponent(next)}`)
|
||||
}
|
||||
|
||||
function isLoginPath(path: string): boolean {
|
||||
return path.includes('/auth/login')
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<{ message: string; body: string }> {
|
||||
let body = ''
|
||||
try {
|
||||
body = await res.text()
|
||||
} catch {
|
||||
body = ''
|
||||
}
|
||||
let message = body
|
||||
if (body) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body)
|
||||
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
||||
const err = (parsed as { error: unknown }).error
|
||||
if (typeof err === 'string' && err !== '') message = err
|
||||
}
|
||||
} catch {
|
||||
/* not JSON: show the raw text */
|
||||
}
|
||||
}
|
||||
if (message === '') message = res.statusText || `HTTP ${res.status}`
|
||||
return { message, body }
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` with JSON conveniences, the API key header and a single 401
|
||||
* redirect. Non-2xx always throws so callers can surface the message.
|
||||
*/
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
|
||||
|
||||
const hasBody = init?.body !== undefined && init?.body !== null
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
|
||||
} catch (err) {
|
||||
// Network failure, DNS, offline, or an aborted request from a unmounted
|
||||
// component: never surface AbortError noise to the user.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err
|
||||
const message = err instanceof Error ? err.message : 'network request failed'
|
||||
throw new ApiError(0, `cannot reach the fancy-dns API: ${message}`)
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// A failed login is not a dead session: do not bounce the login page to
|
||||
// itself.
|
||||
if (!isLoginPath(path)) {
|
||||
clearToken()
|
||||
sendToLogin()
|
||||
}
|
||||
const { message, body } = await errorMessage(res)
|
||||
throw new ApiError(401, message, body)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const { message, body } = await errorMessage(res)
|
||||
throw new ApiError(res.status, message, body)
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
if (text === '') return undefined as T
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
throw new ApiError(res.status, 'the API returned a malformed response', text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Percent-encodes a single path segment. */
|
||||
function seg(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
export async function login(body: LoginInput): Promise<LoginResponse> {
|
||||
const res = await apiFetch<LoginResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res?.token) setToken(res.token)
|
||||
return res
|
||||
}
|
||||
|
||||
export async function logout(): Promise<OkResponse> {
|
||||
try {
|
||||
return await apiFetch<OkResponse>('/api/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
clearToken()
|
||||
}
|
||||
}
|
||||
|
||||
export function whoami(): Promise<WhoamiResponse> {
|
||||
return apiFetch<WhoamiResponse>('/api/whoami')
|
||||
}
|
||||
|
||||
export function getDashboard(range: string): Promise<Dashboard> {
|
||||
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
|
||||
}
|
||||
|
||||
export function getBlocklists(): Promise<BlocklistsResponse> {
|
||||
return apiFetch<BlocklistsResponse>('/api/blocklists')
|
||||
}
|
||||
|
||||
export async function refreshBlocklists(names?: string[]): Promise<RefreshBlocklistsResponse> {
|
||||
if (names && names.length > 0) {
|
||||
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ names }),
|
||||
})
|
||||
}
|
||||
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function toggleBlocklist(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<ToggleBlocklistResponse> {
|
||||
return apiFetch<ToggleBlocklistResponse>('/api/blocklists/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export function getDomains(filters: DomainFilters = {}): Promise<DomainsResponse> {
|
||||
return apiFetch<DomainsResponse>(apiUrl('/api/domains', { ...filters }))
|
||||
}
|
||||
|
||||
export function addDomain(input: AddDomainInput): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>('/api/domains', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDomain(name: string, action?: string, list?: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(apiUrl(`/api/domains/${seg(name)}`, { action, list }), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
||||
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
|
||||
}
|
||||
|
||||
export function getClients(): Promise<ClientsResponse> {
|
||||
return apiFetch<ClientsResponse>('/api/clients')
|
||||
}
|
||||
|
||||
export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
|
||||
return apiFetch<SaveResponse>('/api/clients', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function getUpstreams(): Promise<UpstreamsResponse> {
|
||||
return apiFetch<UpstreamsResponse>('/api/upstreams')
|
||||
}
|
||||
|
||||
export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
|
||||
return apiFetch<SaveResponse>('/api/upstreams', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
|
||||
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
|
||||
}
|
||||
|
||||
export function clearLogs(): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getCache(): Promise<CacheResponse> {
|
||||
return apiFetch<CacheResponse>('/api/cache')
|
||||
}
|
||||
|
||||
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
|
||||
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
|
||||
}
|
||||
|
||||
export function flushCache(): Promise<FlushCacheResponse> {
|
||||
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsResponse> {
|
||||
return apiFetch<SettingsResponse>('/api/settings')
|
||||
}
|
||||
|
||||
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
|
||||
return apiFetch<SaveSettingsResponse>('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config_yaml: yaml }),
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSettings(yaml: string): Promise<ValidateResponse> {
|
||||
return apiFetch<ValidateResponse>('/api/settings/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ config_yaml: yaml }),
|
||||
})
|
||||
}
|
||||
|
||||
export function reloadSettings(): Promise<ReloadSettingsResponse> {
|
||||
return apiFetch<ReloadSettingsResponse>('/api/settings/reload', { method: 'POST' })
|
||||
}
|
||||
|
||||
const STREAM_PATH = '/api/logs/stream'
|
||||
const RECONNECT_MIN_MS = 1000
|
||||
const RECONNECT_MAX_MS = 15000
|
||||
|
||||
/**
|
||||
* Subscribes to the live query log.
|
||||
*
|
||||
* Native `EventSource` cannot send the `X-API-Key` header and the daemon
|
||||
* accepts no cookie, so the stream is read with `fetch` plus a minimal SSE
|
||||
* parser. That costs a hand-rolled reconnect, which is what EventSource would
|
||||
* have given us for free; the handler contract is unchanged.
|
||||
*
|
||||
* Returns a teardown function that is safe to call more than once.
|
||||
*/
|
||||
export function streamLogs(handlers: LogStreamHandlers): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
let backoff = RECONNECT_MIN_MS
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let attempt = 0
|
||||
|
||||
const open = (): void => {
|
||||
if (stopped) return
|
||||
void run()
|
||||
}
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (stopped) return
|
||||
const token = getToken()
|
||||
const headers = new Headers({ Accept: 'text/event-stream' })
|
||||
if (token) headers.set('X-API-Key', token)
|
||||
|
||||
try {
|
||||
const res = await fetch(STREAM_PATH, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
sendToLogin()
|
||||
handlers.onError('unauthorized: the live log stream needs a valid session')
|
||||
return
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
const message = res.ok
|
||||
? 'the live log stream is not supported by this browser'
|
||||
: `the live log stream failed with HTTP ${res.status}`
|
||||
handlers.onError(message)
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
backoff = RECONNECT_MIN_MS
|
||||
handlers.onOpen()
|
||||
await readSse(res.body, handlers, controller.signal)
|
||||
if (stopped) return
|
||||
// A clean end of stream still means the connection is gone.
|
||||
schedule()
|
||||
} catch (err) {
|
||||
if (stopped) return
|
||||
if (controller.signal.aborted) return
|
||||
const message = err instanceof Error ? err.message : 'the live log stream dropped'
|
||||
handlers.onError(message)
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (): void => {
|
||||
if (stopped) return
|
||||
attempt += 1
|
||||
const delay = Math.min(RECONNECT_MAX_MS, backoff)
|
||||
backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8))
|
||||
timer = setTimeout(open, delay)
|
||||
}
|
||||
|
||||
open()
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits an SSE byte stream into `event:`/`data:` frames. */
|
||||
async function readSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
handlers: LogStreamHandlers,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const handle = (frame: string): void => {
|
||||
let event = 'message'
|
||||
const data: string[] = []
|
||||
for (const rawLine of frame.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
if (line === '' || line.startsWith(':')) continue
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
|
||||
}
|
||||
if (data.length === 0) return
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(data.join('\n'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (event === 'entry') {
|
||||
handlers.onEntry(payload as LogItem)
|
||||
} else if (event === 'stats') {
|
||||
handlers.onStats(payload as LiveStats)
|
||||
} else if (event === 'ready') {
|
||||
handlers.onReady(payload as { at?: string; version?: string })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (signal.aborted) return
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let split = buffer.indexOf('\n\n')
|
||||
while (split !== -1) {
|
||||
const frame = buffer.slice(0, split)
|
||||
buffer = buffer.slice(split + 2)
|
||||
handle(frame)
|
||||
split = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
if (buffer.trim() !== '') handle(buffer)
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {
|
||||
/* already released by the abort */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx } from 'clsx'
|
||||
import type { ClassValue } from 'clsx'
|
||||
|
||||
/** Conditional className helper used by every component. */
|
||||
export function cn(...values: ClassValue[]): string {
|
||||
return clsx(values)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/** Consistent human formatting for every number, size and duration in the UI. */
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
|
||||
/** 1_048_576 -> "1.0 MB". Decimal (1000) is used above MB like most disks do. */
|
||||
export function formatBytes(bytes: number | null | undefined, digits = 1): string {
|
||||
const n = typeof bytes === 'number' && Number.isFinite(bytes) ? bytes : 0
|
||||
if (n <= 0) return '0 B'
|
||||
const base = n < 1000 ? 1024 : 1000
|
||||
let value = n
|
||||
let unit = 0
|
||||
while (value >= base && unit < BYTE_UNITS.length - 1) {
|
||||
value /= base
|
||||
unit += 1
|
||||
}
|
||||
const precision = unit === 0 ? 0 : value >= 100 ? 0 : digits
|
||||
return `${value.toFixed(precision)} ${BYTE_UNITS[unit]}`
|
||||
}
|
||||
|
||||
/** 1_234_567 -> "1,234,567". */
|
||||
export function formatNumber(value: number | null | undefined): string {
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
return n.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/** 0.9432 -> "94.3%". Accepts 0..1 (fractions) and 0..100 (percents). */
|
||||
export function formatPercent(fraction: number | null | undefined, digits = 1): string {
|
||||
const n = typeof fraction === 'number' && Number.isFinite(fraction) ? fraction : 0
|
||||
const scaled = n > 1 ? n : n * 100
|
||||
return `${scaled.toFixed(digits)}%`
|
||||
}
|
||||
|
||||
/** 0.043 -> "4.3%" — for rates the API reports as 0..1. */
|
||||
export function formatRate(fraction: number | null | undefined, digits = 1): string {
|
||||
return formatPercent(fraction, digits)
|
||||
}
|
||||
|
||||
/** Milliseconds: 0.42 -> "420 µs", 12.5 -> "12.5 ms", 2400 -> "2.40 s". */
|
||||
export function formatMs(ms: number | null | undefined): string {
|
||||
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
|
||||
if (n <= 0) return '0 ms'
|
||||
if (n < 1) return `${Math.round(n * 1000)} µs`
|
||||
if (n < 1000) return `${n < 10 ? n.toFixed(1) : Math.round(n).toString()} ms`
|
||||
return `${(n / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
/** Seconds -> "3d 4h 12m". */
|
||||
export function formatUptime(seconds: number | null | undefined): string {
|
||||
const total = typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(0, seconds) : 0
|
||||
if (total < 1) return '0s'
|
||||
const days = Math.floor(total / 86400)
|
||||
const hours = Math.floor((total % 86400) / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = Math.floor(total % 60)
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
/** Parses RFC3339 strings and epoch numbers (seconds or milliseconds). */
|
||||
export function toDate(value: string | number | null | undefined): Date | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
const ms = value > 1e11 ? value : value * 1000
|
||||
const d = new Date(ms)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** "3 minutes ago" / "in 2 hours". */
|
||||
export function formatRelative(
|
||||
value: string | number | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return 'never'
|
||||
const diffSec = Math.round((date.getTime() - now) / 1000)
|
||||
const abs = Math.abs(diffSec)
|
||||
if (abs < 5) return 'just now'
|
||||
if (abs < 60) return diffSec < 0 ? `${abs}s ago` : `in ${abs}s`
|
||||
const mins = Math.round(abs / 60)
|
||||
if (mins < 60) return diffSec < 0 ? `${mins}m ago` : `in ${mins}m`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 24) return diffSec < 0 ? `${hours}h ago` : `in ${hours}h`
|
||||
const days = Math.round(hours / 24)
|
||||
if (days < 365) return diffSec < 0 ? `${days}d ago` : `in ${days}d`
|
||||
const years = (days / 365).toFixed(1)
|
||||
return diffSec < 0 ? `${years}y ago` : `in ${years}y`
|
||||
}
|
||||
|
||||
/** "14:03:22" in the viewer's locale. */
|
||||
export function formatClock(value: string | number | Date | null | undefined): string {
|
||||
const date = value instanceof Date ? value : toDate(value ?? null)
|
||||
if (!date) return '--:--:--'
|
||||
return date.toLocaleTimeString('en-GB', { hour12: false })
|
||||
}
|
||||
|
||||
/** "12 Mar 14:03:22". */
|
||||
export function formatDateTime(value: string | number | null | undefined): string {
|
||||
const date = toDate(value ?? null)
|
||||
if (!date) return '—'
|
||||
return `${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })} ${formatClock(
|
||||
date,
|
||||
)}`
|
||||
}
|
||||
|
||||
/** Query/sec-ish compact form for chart axes. */
|
||||
export function formatCompact(value: number | null | undefined): string {
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`
|
||||
return abs >= 10 ? String(Math.round(n)) : n.toFixed(abs >= 1 ? 1 : 2)
|
||||
}
|
||||
|
||||
/** 3.42 -> "3.4" — used for latency bar labels. */
|
||||
export function formatLatency(ms: number | null | undefined): string {
|
||||
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
|
||||
if (n <= 0) return '—'
|
||||
return n < 10 ? n.toFixed(1) : String(Math.round(n))
|
||||
}
|
||||
|
||||
/** DNS rcode number + short name, e.g. 3 -> "3 NXDOMAIN". */
|
||||
export function formatRcode(rcode: number | string | null | undefined): string {
|
||||
const code = typeof rcode === 'number' ? rcode : Number.parseInt(String(rcode ?? ''), 10)
|
||||
if (!Number.isFinite(code)) return '—'
|
||||
return `${code} ${RCODE_NAMES[code] ?? 'UNKNOWN'}`
|
||||
}
|
||||
|
||||
export const RCODE_NAMES: Record<number, string> = {
|
||||
0: 'NOERROR',
|
||||
1: 'FORMERR',
|
||||
2: 'SERVFAIL',
|
||||
3: 'NXDOMAIN',
|
||||
4: 'NOTIMP',
|
||||
5: 'REFUSED',
|
||||
6: 'YXDOMAIN',
|
||||
7: 'YXRRSET',
|
||||
8: 'NXRRSET',
|
||||
9: 'NOTAUTH',
|
||||
10: 'NOTZONE',
|
||||
16: 'BADVERS',
|
||||
17: 'BADKEY',
|
||||
18: 'BADTIME',
|
||||
19: 'BADMODE',
|
||||
20: 'BADNAME',
|
||||
21: 'BADALG',
|
||||
22: 'BADTRUNC',
|
||||
23: 'BADCOOKIE',
|
||||
}
|
||||
|
||||
/** Splits a textarea into trimmed, non-empty lines. */
|
||||
export function toLines(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
/** Joins an array back into a textarea value. */
|
||||
export function fromLines(lines: string[] | null | undefined): string {
|
||||
if (!lines || lines.length === 0) return ''
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/** API shapes, mirroring the Go JSON tags exactly. */
|
||||
|
||||
export type Health = 'ok' | 'degraded' | 'down'
|
||||
export type UpstreamHealth = 'up' | 'down' | 'degraded'
|
||||
export type LogAction = 'allow' | 'block' | 'cached' | 'error' | 'drop'
|
||||
export type DomainAction = 'allow' | 'block'
|
||||
export type ListFormat = 'auto' | 'hosts' | 'adblock' | 'plain' | 'rpz'
|
||||
export type UpstreamProto = 'udp' | 'tcp' | 'tls'
|
||||
export type UpstreamPolicy = 'parallel' | 'random' | 'failover'
|
||||
export type GroupPolicy = 'allow' | 'block_only' | 'deny'
|
||||
export type DefaultPolicy = 'allow' | 'deny' | 'block_only'
|
||||
|
||||
export interface SeriesPoint {
|
||||
t: number
|
||||
queries: number
|
||||
blocked: number
|
||||
errors: number
|
||||
cache_hits: number
|
||||
}
|
||||
|
||||
export interface TopBlockedDomain {
|
||||
domain: string
|
||||
count: number
|
||||
list: string
|
||||
}
|
||||
|
||||
export interface TopQueriedDomain {
|
||||
domain: string
|
||||
count: number
|
||||
qtype: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
export interface UpstreamStat {
|
||||
addr: string
|
||||
proto: string
|
||||
health: UpstreamHealth
|
||||
latency_ms: number
|
||||
fail_rate: number
|
||||
queries: number
|
||||
avg_ms: number
|
||||
p95_ms: number
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
health: Health
|
||||
version: string
|
||||
node_id: string
|
||||
uptime_seconds: number
|
||||
queries_total: number
|
||||
queries_per_sec: number
|
||||
cache_hit_rate: number
|
||||
blocked_total: number
|
||||
blocked_pct: number
|
||||
series: SeriesPoint[]
|
||||
top_blocked: TopBlockedDomain[]
|
||||
top_queried: TopQueriedDomain[]
|
||||
upstreams: UpstreamStat[]
|
||||
}
|
||||
|
||||
export interface BlocklistItem {
|
||||
name: string
|
||||
format: ListFormat | string
|
||||
enabled: boolean
|
||||
url: string
|
||||
path: string
|
||||
rules: number
|
||||
bytes: number
|
||||
updated_at: string
|
||||
duration_ms: number
|
||||
err: string
|
||||
etag: string
|
||||
categories: string[]
|
||||
stale: boolean
|
||||
last_modified: string
|
||||
}
|
||||
|
||||
export interface BlocklistStats {
|
||||
total: number
|
||||
exact: number
|
||||
subdomain_rules: number
|
||||
root_rules: number
|
||||
allow: number
|
||||
block: number
|
||||
duplicates: number
|
||||
sources: number
|
||||
built_at: string
|
||||
}
|
||||
|
||||
export interface BlocklistsResponse {
|
||||
items: BlocklistItem[]
|
||||
stats: BlocklistStats
|
||||
}
|
||||
|
||||
export interface DomainItem {
|
||||
name: string
|
||||
action: DomainAction | string
|
||||
list: string
|
||||
source: string
|
||||
categories: string[]
|
||||
hits: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DomainsResponse {
|
||||
items: DomainItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface DomainCheck {
|
||||
name: string
|
||||
action: DomainAction | string
|
||||
source: string
|
||||
categories: string[]
|
||||
hits: number
|
||||
rule: string
|
||||
matched_pattern: string
|
||||
}
|
||||
|
||||
export interface ClientMatch {
|
||||
cidrs: string[]
|
||||
ptr_suffixes: string[]
|
||||
tags: string[]
|
||||
any: boolean
|
||||
}
|
||||
|
||||
export interface ClientGroup {
|
||||
name: string
|
||||
comment: string
|
||||
policy: GroupPolicy | string
|
||||
match: ClientMatch
|
||||
allow_domains: string[]
|
||||
deny_domains: string[]
|
||||
skip_blocklist: boolean
|
||||
skip_cache: boolean
|
||||
rate_limit: number
|
||||
max_concurrent: number
|
||||
log_queries: boolean
|
||||
upstreams: string[]
|
||||
}
|
||||
|
||||
export interface ClientsResponse {
|
||||
groups: ClientGroup[]
|
||||
default_policy: DefaultPolicy | string
|
||||
}
|
||||
|
||||
export interface UpstreamServer {
|
||||
addr: string
|
||||
proto: UpstreamProto | string
|
||||
server_name: string
|
||||
weight: number
|
||||
comment: string
|
||||
health: UpstreamHealth
|
||||
latency_ms: number
|
||||
fail_rate: number
|
||||
queries: number
|
||||
avg_ms: number
|
||||
p95_ms: number
|
||||
breaker_open: boolean
|
||||
}
|
||||
|
||||
export interface UpstreamBootstrap {
|
||||
name: string
|
||||
addresses: string[]
|
||||
refresh: string
|
||||
}
|
||||
|
||||
export interface UpstreamsResponse {
|
||||
policy: UpstreamPolicy | string
|
||||
servers: UpstreamServer[]
|
||||
bootstraps: UpstreamBootstrap[]
|
||||
healthcheck_interval: string
|
||||
max_attempts: number
|
||||
breaker_threshold: number
|
||||
}
|
||||
|
||||
export interface LogItem {
|
||||
id: number | string
|
||||
ts: string
|
||||
client: string
|
||||
client_group: string
|
||||
name: string
|
||||
qtype: string
|
||||
rcode: number
|
||||
action: LogAction | string
|
||||
response: string
|
||||
duration_ms: number
|
||||
upstream: string
|
||||
list: string
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
items: LogItem[]
|
||||
total: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface LiveStats {
|
||||
qps: number
|
||||
blocked_pct: number
|
||||
cache_hit_rate: number
|
||||
}
|
||||
|
||||
export interface CachePersisted {
|
||||
enabled: boolean
|
||||
path: string
|
||||
bytes: number
|
||||
entries: number
|
||||
last_snapshot: string
|
||||
writes: number
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
enabled: boolean
|
||||
size: number
|
||||
entries: number
|
||||
shards: number
|
||||
min_ttl_ms: number
|
||||
max_ttl_ms: number
|
||||
serve_stale_ms: number
|
||||
negative_ttl_ms: number
|
||||
hit_rate: number
|
||||
hits: number
|
||||
misses: number
|
||||
stale_hits: number
|
||||
evictions: number
|
||||
persisted: CachePersisted
|
||||
}
|
||||
|
||||
export interface CacheTopEntry {
|
||||
name: string
|
||||
qtype: string
|
||||
count: number
|
||||
hits: number
|
||||
last_seen: string
|
||||
}
|
||||
|
||||
export interface CacheTopResponse {
|
||||
items: CacheTopEntry[]
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
version: string
|
||||
path: string
|
||||
config_yaml: string
|
||||
effective_yaml: string
|
||||
defaults_yaml: string
|
||||
last_error: string
|
||||
reload_available: boolean
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
export interface SaveSettingsResponse {
|
||||
ok: boolean
|
||||
version: string
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface ConfigIssue {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ValidateResponse {
|
||||
ok: boolean
|
||||
errors: ConfigIssue[]
|
||||
warnings: ConfigIssue[]
|
||||
}
|
||||
|
||||
export interface OkResponse {
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
ok: boolean
|
||||
token?: string
|
||||
}
|
||||
|
||||
export interface DomainFilters {
|
||||
q?: string
|
||||
action?: string
|
||||
list?: string
|
||||
page?: number
|
||||
per_page?: number
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
limit?: number
|
||||
offset?: number
|
||||
q?: string
|
||||
action?: string
|
||||
client?: string
|
||||
rcode?: string
|
||||
blocked?: boolean
|
||||
since?: string
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Blocklists — the compiled rule sources.
|
||||
*
|
||||
* Two read paths (`/api/blocklists`) and three mutations (toggle, refresh one,
|
||||
* refresh all). Toggles are optimistic because the endpoint is a single write
|
||||
* to an in-memory flag: the switch has to feel instant or it feels broken.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
|
||||
|
||||
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api'
|
||||
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../../hooks/useDebounced'
|
||||
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
StatCard,
|
||||
Switch,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
}
|
||||
|
||||
const EMPTY_STATS: BlocklistStats = {
|
||||
total: 0,
|
||||
exact: 0,
|
||||
subdomain_rules: 0,
|
||||
root_rules: 0,
|
||||
allow: 0,
|
||||
block: 0,
|
||||
duplicates: 0,
|
||||
sources: 0,
|
||||
built_at: '',
|
||||
}
|
||||
|
||||
type CheckView = Partial<DomainCheck> & {
|
||||
list?: string
|
||||
pattern?: string
|
||||
error?: string
|
||||
/** Older daemons called the matched rule "rule". */
|
||||
rule?: string
|
||||
matched_pattern?: string
|
||||
}
|
||||
|
||||
/** The lookup endpoint answers 200 with an `error` string, not a 4xx. */
|
||||
function checkView(value: unknown): CheckView | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
return value as CheckView
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 42): string {
|
||||
if (value.length <= max) return value
|
||||
return `${value.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
export default function Blocklists() {
|
||||
const toast = useToast()
|
||||
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
|
||||
useAutoRefresh(refetch, 15000)
|
||||
|
||||
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
|
||||
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
|
||||
const [probe, setProbe] = useState('')
|
||||
const debouncedProbe = useDebounced(probe, 300)
|
||||
const name = debouncedProbe.trim()
|
||||
|
||||
const { data: checked, error: checkError, loading: checking } = useApi(
|
||||
() => (name.length > 0 ? checkDomain(name) : Promise.resolve(null)),
|
||||
[name],
|
||||
)
|
||||
const result = checkView(checked)
|
||||
|
||||
const stats = (data?.stats as BlocklistStats | undefined) ?? EMPTY_STATS
|
||||
const items: BlocklistItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const compiledAgo = useMemo(() => {
|
||||
const built = toDate(stats.built_at)
|
||||
if (built) return formatRelative(built.toISOString())
|
||||
const newest = items
|
||||
.map((i) => toDate(i.updated_at))
|
||||
.filter((d): d is Date => d !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
return newest ? formatRelative(newest.toISOString()) : 'never'
|
||||
}, [stats.built_at, items])
|
||||
|
||||
const toggle = useAsyncAction(async (list: string, enabled: boolean) =>
|
||||
toggleBlocklist(list, enabled),
|
||||
)
|
||||
const refresh = useAsyncAction(async (names?: string[]) => refreshBlocklists(names))
|
||||
|
||||
const onToggle = async (item: BlocklistItem, next: boolean): Promise<void> => {
|
||||
setOverrides((prev) => ({ ...prev, [item.name]: next }))
|
||||
const applied = await toggle.run(item.name, next)
|
||||
if (applied === null) {
|
||||
setOverrides((prev) => {
|
||||
const copy = { ...prev }
|
||||
delete copy[item.name]
|
||||
return copy
|
||||
})
|
||||
notify(toast, 'fail', toggle.error ?? `could not toggle ${item.name}`)
|
||||
return
|
||||
}
|
||||
refetch()
|
||||
}
|
||||
|
||||
const onRefresh = async (names?: string[]): Promise<void> => {
|
||||
const key = names?.join(',') ?? ''
|
||||
setPendingRows((prev) => ({ ...prev, [key]: true }))
|
||||
const res = await refresh.run(names)
|
||||
setPendingRows((prev) => ({ ...prev, [key]: false }))
|
||||
if (res === null) {
|
||||
notify(toast, 'fail', refresh.error ?? 'blocklist refresh failed')
|
||||
return
|
||||
}
|
||||
notify(
|
||||
toast,
|
||||
'ok',
|
||||
names && names.length > 0
|
||||
? `refresh started for ${names.join(', ')}`
|
||||
: 'refresh started for every source',
|
||||
)
|
||||
refetch()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', header: 'List', width: '22%' },
|
||||
{ key: 'format', header: 'Format', width: '10%' },
|
||||
{ key: 'enabled', header: 'Enabled', width: '10%' },
|
||||
{ key: 'rules', header: 'Rules', width: '9%', align: 'right' as const },
|
||||
{ key: 'size', header: 'Size', width: '9%', align: 'right' as const },
|
||||
{ key: 'updated', header: 'Updated', width: '12%' },
|
||||
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
|
||||
{ key: 'source', header: 'Source', width: '24%' },
|
||||
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const rows: Row[] = items.map((item) => {
|
||||
const enabled = overrides[item.name] ?? item.enabled
|
||||
const busy = pendingRows[item.name] === true
|
||||
return {
|
||||
__key: item.name,
|
||||
name: (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{item.name}</span>
|
||||
{item.err ? (
|
||||
<span className="truncate text-xs text-red-600 dark:text-red-400">{item.err}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
format: <Badge tone="muted">{item.format}</Badge>,
|
||||
enabled: (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(next) => void onToggle(item, next)}
|
||||
label={enabled ? 'on' : 'off'}
|
||||
/>
|
||||
),
|
||||
rules: <span className="font-mono text-xs">{formatNumber(item.rules)}</span>,
|
||||
size: <span className="font-mono text-xs">{formatBytes(item.bytes)}</span>,
|
||||
updated: (
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap text-xs">
|
||||
{formatRelative(item.updated_at)}
|
||||
{item.stale ? <Badge tone="warn">stale</Badge> : null}
|
||||
</span>
|
||||
),
|
||||
duration: <span className="font-mono text-xs">{formatMs(item.duration_ms)}</span>,
|
||||
source: (
|
||||
<span
|
||||
className="block max-w-[240px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
|
||||
title={item.url || item.path}
|
||||
>
|
||||
{truncate(item.url || item.path)}
|
||||
</span>
|
||||
),
|
||||
refresh: (
|
||||
<IconButton
|
||||
icon={<RefreshCw size={14} />}
|
||||
label={`refresh ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void onRefresh([item.name])}
|
||||
/>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const action = result?.action ?? null
|
||||
const matchedList = result?.source || result?.list || ''
|
||||
const matchedPattern = result?.matched_pattern || result?.rule || result?.pattern || ''
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Blocklists"
|
||||
description="Compiled rule sources, their freshness and their health."
|
||||
actions={
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => void onRefresh()}
|
||||
loading={refresh.pending}
|
||||
>
|
||||
Refresh all
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} />
|
||||
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
|
||||
<StatCard
|
||||
label="Allow rules"
|
||||
value={formatNumber(stats.allow)}
|
||||
icon={<ShieldCheck size={16} />}
|
||||
tone={stats.allow > 0 ? 'default' : 'warn'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Duplicates"
|
||||
value={formatNumber(stats.duplicates)}
|
||||
icon={<Split size={16} />}
|
||||
tone={stats.duplicates > 0 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Last compile"
|
||||
value={compiledAgo}
|
||||
icon={<Clock size={16} />}
|
||||
hint={stats.built_at ? undefined : 'no build recorded'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Test a domain"
|
||||
subtitle="How would the current blocklists treat this name? Nothing is changed."
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start">
|
||||
<div className="w-full md:max-w-sm">
|
||||
<Input
|
||||
label="Domain"
|
||||
placeholder="ads.example.com"
|
||||
value={probe}
|
||||
onChange={(e) => setProbe(e.target.value)}
|
||||
icon={<ShieldCheck size={14} />}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{!name ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Type a name to see the matching rule, the list it came from and how often it
|
||||
fired.
|
||||
</p>
|
||||
) : checking ? (
|
||||
<p className="text-sm text-slate-500">Checking {name}…</p>
|
||||
) : checkError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{checkError}</p>
|
||||
) : result?.error ? (
|
||||
<p className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
{result.error}
|
||||
</p>
|
||||
) : result ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge tone={action === 'allow' ? 'default' : 'warn'}>{action ?? 'unknown'}</Badge>
|
||||
<span className="font-mono text-xs">{name}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
list: <span className="font-mono">{matchedList || '—'}</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
rule: <span className="font-mono">{matchedPattern || '—'}</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
hits: <span className="font-mono">{formatNumber(result.hits ?? 0)}</span>
|
||||
</span>
|
||||
{Array.isArray(result.categories) && result.categories.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{result.categories.map((c) => (
|
||||
<Badge key={c} tone="info">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No answer for {name}.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card title="Sources" subtitle={`${items.length} configured`} padded={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={loading && items.length === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No blocklists configured"
|
||||
description="Add list sources in the configuration to compile rules."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Removing a list is a configuration change: delete its source in Settings, then refresh.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Clients — per-client ACL groups.
|
||||
*
|
||||
* The whole file is rewritten on every save (`PUT /api/clients`), so the editor
|
||||
* works on a local draft and only commits on Save. A 400 comes back with the
|
||||
* offending field paths, which are mapped back onto the draft fields so the
|
||||
* user sees what is wrong next to the input rather than in a toast alone.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
|
||||
|
||||
import { ApiError, getClients, putClients } from '../../lib/api'
|
||||
import type { ClientGroup, ClientMatch } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { fromLines, toLines } from '../../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
}
|
||||
|
||||
const POLICIES = [
|
||||
{ value: 'allow', label: 'Allow — resolve everything' },
|
||||
{ value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
|
||||
{ value: 'deny', label: 'Deny — refuse everything' },
|
||||
]
|
||||
|
||||
const DEFAULT_POLICIES = [
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'block_only', label: 'Block only' },
|
||||
{ value: 'deny', label: 'Deny' },
|
||||
]
|
||||
|
||||
/** Textarea-friendly draft: numbers stay strings until they are parsed. */
|
||||
type GroupDraft = {
|
||||
name: string
|
||||
comment: string
|
||||
policy: string
|
||||
cidrs: string
|
||||
ptr_suffixes: string
|
||||
tags: string
|
||||
any: boolean
|
||||
allow_domains: string
|
||||
deny_domains: string
|
||||
skip_blocklist: boolean
|
||||
skip_cache: boolean
|
||||
log_queries: boolean
|
||||
rate_limit: string
|
||||
max_concurrent: string
|
||||
upstreams: string
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: GroupDraft = {
|
||||
name: '',
|
||||
comment: '',
|
||||
policy: 'allow',
|
||||
cidrs: '',
|
||||
ptr_suffixes: '',
|
||||
tags: '',
|
||||
any: false,
|
||||
allow_domains: '',
|
||||
deny_domains: '',
|
||||
skip_blocklist: false,
|
||||
skip_cache: false,
|
||||
log_queries: false,
|
||||
rate_limit: '0',
|
||||
max_concurrent: '0',
|
||||
upstreams: '',
|
||||
}
|
||||
|
||||
function toNumber(value: string): number {
|
||||
const n = Number.parseInt(value.trim(), 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function matchOf(g: ClientGroup): ClientMatch {
|
||||
const m = g.match
|
||||
return {
|
||||
cidrs: m?.cidrs ?? [],
|
||||
ptr_suffixes: m?.ptr_suffixes ?? [],
|
||||
tags: m?.tags ?? [],
|
||||
any: m?.any ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** One line describing what the group matches, e.g. "10.0.0.0/8, .lan · *". */
|
||||
function matchSummary(g: ClientGroup): string {
|
||||
const m = matchOf(g)
|
||||
const bits: string[] = []
|
||||
if (m.any) bits.push('any client')
|
||||
if (m.cidrs.length > 0) {
|
||||
const shown = m.cidrs.slice(0, 2).join(', ')
|
||||
bits.push(m.cidrs.length > 2 ? `${shown} +${m.cidrs.length - 2}` : shown)
|
||||
}
|
||||
if (m.ptr_suffixes.length > 0) bits.push(m.ptr_suffixes.join(', '))
|
||||
if (m.tags.length > 0) bits.push(m.tags.map((t) => `#${t}`).join(' '))
|
||||
if (bits.length === 0) return 'matches nothing — this group is inactive'
|
||||
return bits.join(' · ')
|
||||
}
|
||||
|
||||
function fromGroup(g: ClientGroup): GroupDraft {
|
||||
const m = matchOf(g)
|
||||
return {
|
||||
name: g.name ?? '',
|
||||
comment: g.comment ?? '',
|
||||
policy: String(g.policy ?? 'allow'),
|
||||
cidrs: fromLines(m.cidrs),
|
||||
ptr_suffixes: fromLines(m.ptr_suffixes),
|
||||
tags: fromLines(m.tags),
|
||||
any: m.any,
|
||||
allow_domains: fromLines(g.allow_domains),
|
||||
deny_domains: fromLines(g.deny_domains),
|
||||
skip_blocklist: g.skip_blocklist === true,
|
||||
skip_cache: g.skip_cache === true,
|
||||
log_queries: g.log_queries === true,
|
||||
rate_limit: String(g.rate_limit ?? 0),
|
||||
max_concurrent: String(g.max_concurrent ?? 0),
|
||||
upstreams: fromLines(g.upstreams),
|
||||
}
|
||||
}
|
||||
|
||||
function toGroup(d: GroupDraft): ClientGroup {
|
||||
return {
|
||||
name: d.name.trim(),
|
||||
comment: d.comment,
|
||||
policy: d.policy,
|
||||
match: {
|
||||
cidrs: toLines(d.cidrs),
|
||||
ptr_suffixes: toLines(d.ptr_suffixes),
|
||||
tags: toLines(d.tags),
|
||||
any: d.any,
|
||||
},
|
||||
allow_domains: toLines(d.allow_domains),
|
||||
deny_domains: toLines(d.deny_domains),
|
||||
skip_blocklist: d.skip_blocklist,
|
||||
skip_cache: d.skip_cache,
|
||||
rate_limit: toNumber(d.rate_limit),
|
||||
max_concurrent: toNumber(d.max_concurrent),
|
||||
log_queries: d.log_queries,
|
||||
upstreams: toLines(d.upstreams),
|
||||
}
|
||||
}
|
||||
|
||||
/** `groups[2].match.cidrs` -> `cidrs`. */
|
||||
function fieldLeaf(path: string): string {
|
||||
const cleaned = path.replace(/^groups\[\d+\]\.?/, '').replace(/^groups\.\d+\.?/, '')
|
||||
const parts = cleaned.split('.')
|
||||
return parts[parts.length - 1] ?? cleaned
|
||||
}
|
||||
|
||||
export default function Clients() {
|
||||
const toast = useToast()
|
||||
const { data, error, loading, refetch } = useApi(() => getClients(), [])
|
||||
|
||||
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
|
||||
const [defaultPolicy, setDefaultPolicy] = useState<string>('')
|
||||
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [fieldErrors, setFieldErrors] = useState<string[]>([])
|
||||
const [pendingDelete, setPendingDelete] = useState<number | null>(null)
|
||||
|
||||
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
|
||||
|
||||
const save = useAsyncAction(async (payload: { groups: ClientGroup[]; default_policy: string }) => {
|
||||
try {
|
||||
return await putClients(payload)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(err.body)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const fields = (parsed as { fields?: unknown }).fields
|
||||
if (Array.isArray(fields)) {
|
||||
setFieldErrors(fields.filter((f): f is string => typeof f === 'string'))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* a 400 without JSON is still reported through the toast */
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
const fieldError = (key: string): string | undefined => {
|
||||
const leaf = fieldLeaf(key)
|
||||
return fieldErrors.find((f) => fieldLeaf(f) === leaf)
|
||||
}
|
||||
|
||||
const commit = async (
|
||||
nextGroups: ClientGroup[],
|
||||
nextDefault: string,
|
||||
message: string,
|
||||
): Promise<boolean> => {
|
||||
const res = await save.run({ groups: nextGroups, default_policy: nextDefault })
|
||||
if (res === null) {
|
||||
notify(toast, 'fail', save.error ?? 'the server rejected the client groups')
|
||||
return false
|
||||
}
|
||||
notify(toast, 'ok', message)
|
||||
refetch()
|
||||
return true
|
||||
}
|
||||
|
||||
const openNew = (): void => {
|
||||
setEditingIndex(null)
|
||||
setDraft(EMPTY_DRAFT)
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (index: number): void => {
|
||||
const group = groups[index]
|
||||
if (!group) return
|
||||
setEditingIndex(index)
|
||||
setDraft(fromGroup(group))
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const duplicate = (index: number): void => {
|
||||
const group = groups[index]
|
||||
if (!group) return
|
||||
const copy = fromGroup(group)
|
||||
setEditingIndex(null)
|
||||
setDraft({ ...copy, name: `${copy.name}-copy` })
|
||||
setFieldErrors([])
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const saveGroup = async (): Promise<void> => {
|
||||
if (draft.name.trim() === '') {
|
||||
setFieldErrors(['name'])
|
||||
notify(toast, 'fail', 'a group needs a name')
|
||||
return
|
||||
}
|
||||
const group = toGroup(draft)
|
||||
const next = [...groups]
|
||||
if (editingIndex !== null && next[editingIndex]) {
|
||||
next[editingIndex] = group
|
||||
} else {
|
||||
next.push(group)
|
||||
}
|
||||
if (await commit(next, effectiveDefault, `saved ${group.name}`)) {
|
||||
setEditorOpen(false)
|
||||
setFieldErrors([])
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async (): Promise<void> => {
|
||||
const index = pendingDelete
|
||||
if (index === null) return
|
||||
const group = groups[index]
|
||||
if (!group) return
|
||||
const next = groups.filter((_, i) => i !== index)
|
||||
if (await commit(next, effectiveDefault, `deleted ${group.name}`)) {
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDefault = async (next: string): Promise<void> => {
|
||||
setDefaultDraft(next)
|
||||
const ok = await commit(groups, next, `default policy set to ${next}`)
|
||||
if (!ok) {
|
||||
setDefaultDraft(null)
|
||||
return
|
||||
}
|
||||
setDefaultDraft(null)
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
|
||||
actions={
|
||||
<Button icon={<Plus size={14} />} onClick={openNew}>
|
||||
Add group
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="Default policy" subtitle="Applied to clients that match no group.">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<Select
|
||||
label="Policy"
|
||||
value={effectiveDefault}
|
||||
disabled={loading || save.pending}
|
||||
onChange={(e) => void saveDefault(e.target.value)}
|
||||
options={DEFAULT_POLICIES}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users size={20} />}
|
||||
title="No client groups"
|
||||
description="Without a group every client gets the default policy."
|
||||
action={
|
||||
<Button icon={<Plus size={14} />} onClick={openNew}>
|
||||
Add the first group
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{groups.map((group, i) => (
|
||||
<Card key={group.name}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-medium">{group.name}</h3>
|
||||
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
|
||||
{group.policy}
|
||||
</Badge>
|
||||
</div>
|
||||
{group.comment ? (
|
||||
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
|
||||
) : null}
|
||||
<p className="mt-1.5 truncate font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{matchSummary(group)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
|
||||
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
|
||||
{group.log_queries ? <Badge tone="muted">log</Badge> : null}
|
||||
{group.rate_limit > 0 ? (
|
||||
<Badge tone="muted">{group.rate_limit} qps</Badge>
|
||||
) : null}
|
||||
{group.max_concurrent > 0 ? (
|
||||
<Badge tone="muted">max {group.max_concurrent}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
|
||||
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
|
||||
<IconButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label={`delete ${group.name}`}
|
||||
variant="ghost"
|
||||
onClick={() => setPendingDelete(i)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
|
||||
description="Groups are matched top to bottom; the first match wins."
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void saveGroup()} loading={save.pending}>
|
||||
{editingIndex === null ? 'Create group' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
error={fieldError('name')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Select
|
||||
label="Policy"
|
||||
value={draft.policy}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
|
||||
options={POLICIES}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Comment"
|
||||
value={draft.comment}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Textarea
|
||||
label="Client CIDRs"
|
||||
hint="One per line, e.g. 192.168.1.0/24"
|
||||
monospace
|
||||
rows={4}
|
||||
value={draft.cidrs}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
|
||||
error={fieldError('cidrs')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reverse-DNS suffixes"
|
||||
hint="One per line, e.g. .lan"
|
||||
monospace
|
||||
rows={4}
|
||||
value={draft.ptr_suffixes}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
|
||||
error={fieldError('ptr_suffixes')}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Tags"
|
||||
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.tags}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
|
||||
error={fieldError('tags')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Textarea
|
||||
label="Allow domains"
|
||||
hint="Glob patterns that skip the blocklists for this group."
|
||||
monospace
|
||||
rows={3}
|
||||
value={draft.allow_domains}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
|
||||
error={fieldError('allow_domains')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Deny domains"
|
||||
hint="Glob patterns always refused for this group."
|
||||
monospace
|
||||
rows={3}
|
||||
value={draft.deny_domains}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
|
||||
error={fieldError('deny_domains')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label="Upstreams"
|
||||
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
|
||||
monospace
|
||||
rows={2}
|
||||
value={draft.upstreams}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
|
||||
error={fieldError('upstreams')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Rate limit (qps)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.rate_limit}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
|
||||
hint="0 disables the limit."
|
||||
error={fieldError('rate_limit')}
|
||||
/>
|
||||
<Input
|
||||
label="Max concurrent queries"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.max_concurrent}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
|
||||
hint="0 means unlimited."
|
||||
error={fieldError('max_concurrent')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Switch
|
||||
checked={draft.any}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, any: next }))}
|
||||
label="Match any client"
|
||||
description="Last-resort group that matches everything not claimed above."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.skip_blocklist}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, skip_blocklist: next }))}
|
||||
label="Skip blocklists"
|
||||
description="Resolve everything, even listed names."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.skip_cache}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
|
||||
label="Skip cache"
|
||||
description="Always go upstream, never serve a cached answer."
|
||||
/>
|
||||
<Switch
|
||||
checked={draft.log_queries}
|
||||
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
|
||||
label="Log queries"
|
||||
description="Include this group's queries in the query log."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fieldErrors.length > 0 ? (
|
||||
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p className="font-medium">The server rejected these fields:</p>
|
||||
<ul className="mt-1 list-inside list-disc font-mono text-xs">
|
||||
{fieldErrors.map((f) => (
|
||||
<li key={f}>{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={() => void deleteGroup()}
|
||||
title="Delete this group?"
|
||||
message={
|
||||
pendingDelete !== null && groups[pendingDelete]
|
||||
? `Clients matching "${groups[pendingDelete].name}" fall back to the default policy.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={save.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Dashboard — the "is it working" page.
|
||||
*
|
||||
* Everything on it is a single `/api/dashboard` fetch for the selected range;
|
||||
* the charts are pure functions of `series`, so they never need their own
|
||||
* polling. The server builds that payload, so every field is read defensively:
|
||||
* a node running an older build simply shows less, it never throws.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip as ChartTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react'
|
||||
|
||||
import { getDashboard } from '../../lib/api'
|
||||
import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types'
|
||||
import { useApi } from '../../hooks/useApi'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange'
|
||||
import {
|
||||
formatClock,
|
||||
formatCompact,
|
||||
formatMs,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatUptime,
|
||||
} from '../../lib/format'
|
||||
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
|
||||
import { HealthPill, PageError, PageHeader } from '../components/layout'
|
||||
|
||||
/** Chart palette: fixed hex so it reads on both the light and dark theme. */
|
||||
const GRID = 'rgba(148, 163, 184, 0.22)'
|
||||
const AXIS_TEXT = '#94a3b8'
|
||||
const QUERIES = '#38bdf8'
|
||||
const CACHED = '#34d399'
|
||||
const BLOCKED = '#f59e0b'
|
||||
const ERRORS = '#f87171'
|
||||
const TIP_BG = '#0f172a'
|
||||
|
||||
/** The parts of the dashboard payload that older daemons may not send. */
|
||||
type DashboardView = Partial<DashboardData> & {
|
||||
degraded_reason?: string
|
||||
reason?: string
|
||||
message?: string
|
||||
latency?: { p50_ms?: number; p95_ms?: number; p99_ms?: number }
|
||||
recent?: LogItem[]
|
||||
}
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
function view(data: DashboardData | null): DashboardView {
|
||||
return (data ?? {}) as DashboardView
|
||||
}
|
||||
|
||||
/** `useToast()` shape is the UI kit's business; this just needs a call. */
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
}
|
||||
|
||||
type TipProps = {
|
||||
active?: boolean
|
||||
label?: number | string
|
||||
payload?: { name?: string; value?: number | string; color?: string }[]
|
||||
}
|
||||
|
||||
/** Dark tooltip content, so the chart stays legible in the light theme too. */
|
||||
function ChartTip({ active, payload, label }: TipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
|
||||
<div className="mb-1 font-mono text-slate-400">{formatClock(Number(label) * 1000)}</div>
|
||||
<ul className="space-y-0.5">
|
||||
{payload.map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-2 text-slate-100">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
<span className="text-slate-400">{item.name}</span>
|
||||
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function healthTone(health: string | undefined): 'default' | 'ok' | 'warn' | 'danger' {
|
||||
if (health === 'ok') return 'ok'
|
||||
if (health === 'degraded') return 'warn'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function seriesOf(d: DashboardView) {
|
||||
return d.series ?? []
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { range, setRange } = useTimeRange('1h')
|
||||
const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range])
|
||||
useAutoRefresh(refetch, 5000)
|
||||
const d = view(data)
|
||||
|
||||
const series = useMemo(() => seriesOf(d), [d])
|
||||
const upstreams = useMemo(() => d.upstreams ?? [], [d])
|
||||
const slowest = useMemo(
|
||||
() => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0),
|
||||
[upstreams],
|
||||
)
|
||||
const topBlocked = useMemo(() => d.top_blocked ?? [], [d])
|
||||
const topQueried = useMemo(() => d.top_queried ?? [], [d])
|
||||
|
||||
const hitRate = d.cache_hit_rate ?? 0
|
||||
const p99 = d.latency?.p99_ms
|
||||
const p95Fallback = useMemo(() => {
|
||||
const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0)
|
||||
return values.length > 0 ? Math.max(...values) : undefined
|
||||
}, [upstreams])
|
||||
const latencyValue = p99 ?? p95Fallback
|
||||
const latencyLabel = p99 != null ? 'p99 latency' : 'p95 latency'
|
||||
const degraded = d.health != null && d.health !== 'ok'
|
||||
const degradedReason =
|
||||
d.degraded_reason ?? d.reason ?? d.message ?? 'no reason reported by the server'
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const upstreamColumns = [
|
||||
{ key: 'addr', header: 'Server', width: '28%' },
|
||||
{ key: 'proto', header: 'Proto', width: '8%' },
|
||||
{ key: 'health', header: 'Health', width: '12%' },
|
||||
{ key: 'latency', header: 'Latency', width: '22%' },
|
||||
{ key: 'fail', header: 'Fail rate', width: '12%', align: 'right' as const },
|
||||
{ key: 'queries', header: 'Queries', width: '14%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const upstreamRows: Row[] = upstreams.map((u: UpstreamStat, i) => ({
|
||||
__key: `${u.addr}-${i}`,
|
||||
addr: <span className="font-mono text-xs">{u.addr}</span>,
|
||||
proto: <Badge tone="muted">{u.proto}</Badge>,
|
||||
health: <Badge tone={healthTone(u.health)}>{u.health}</Badge>,
|
||||
latency: (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-12 shrink-0 font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatMs(u.latency_ms)}
|
||||
</span>
|
||||
<span className="w-full min-w-[60px]">
|
||||
<Progress
|
||||
value={u.latency_ms}
|
||||
max={slowest || 1}
|
||||
tone={u.fail_rate > 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
fail: (
|
||||
<span className="font-mono text-xs">{u.fail_rate > 0 ? formatRate(u.fail_rate) : '—'}</span>
|
||||
),
|
||||
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</span>,
|
||||
}))
|
||||
|
||||
const recent: LogItem[] = d.recent ?? []
|
||||
const recentSource: { name: string; list: string; action: string; ts?: string }[] =
|
||||
recent.length > 0
|
||||
? recent.slice(0, 10).map((e) => ({
|
||||
name: e.name,
|
||||
list: e.list,
|
||||
action: e.action,
|
||||
ts: e.ts,
|
||||
}))
|
||||
: topBlocked.slice(0, 10).map((e) => ({ name: e.domain, list: e.list, action: 'block' }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Live traffic, cache efficiency and upstream health."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
|
||||
role="group"
|
||||
aria-label="Time range"
|
||||
>
|
||||
{TIME_RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={
|
||||
range === r
|
||||
? 'bg-slate-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={refetch}
|
||||
loading={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{degraded && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
<Activity size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">The server reports degraded service</p>
|
||||
<p className="mt-0.5 break-words opacity-90">{degradedReason}</p>
|
||||
</div>
|
||||
<Badge tone={healthTone(d.health)}>{d.health}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Queries / s"
|
||||
value={formatCompact(d.queries_per_sec ?? 0)}
|
||||
hint={`${formatNumber(d.queries_total ?? 0)} total`}
|
||||
icon={<Activity size={16} />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cache hit rate"
|
||||
value={formatRate(hitRate)}
|
||||
hint="higher is better"
|
||||
icon={<Gauge size={16} />}
|
||||
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={formatPercent(d.blocked_pct ?? 0)}
|
||||
hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
|
||||
icon={<Ban size={16} />}
|
||||
/>
|
||||
<StatCard
|
||||
label={latencyLabel}
|
||||
value={latencyValue != null ? formatMs(latencyValue) : '—'}
|
||||
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
|
||||
icon={<Timer size={16} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds ?? 0)}
|
||||
{d.node_id ? ` · node ${d.node_id}` : ''} · auto-refreshing every 5s
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card title="Queries" subtitle={`Last ${range} · total, cache hits and errors`}>
|
||||
<CardBody>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickFormatter={formatClock}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCompact}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="queries"
|
||||
name="queries"
|
||||
stroke={QUERIES}
|
||||
fill={QUERIES}
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cache_hits"
|
||||
name="cached"
|
||||
stroke={CACHED}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="errors"
|
||||
name="errors"
|
||||
stroke={ERRORS}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card title="Blocked rate" subtitle={`Blocked queries per bucket, last ${range}`}>
|
||||
<CardBody>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickFormatter={formatClock}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCompact}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
width={48}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
|
||||
<Bar
|
||||
dataKey="blocked"
|
||||
name="blocked"
|
||||
fill={BLOCKED}
|
||||
radius={[3, 3, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="queries"
|
||||
name="queries"
|
||||
stroke={QUERIES}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card title="Top blocked domains" subtitle="Ranked by hits in the selected range">
|
||||
<CardBody>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={topBlocked}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 16, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke={GRID} strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={formatCompact}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="domain"
|
||||
width={180}
|
||||
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
|
||||
stroke={GRID}
|
||||
tickFormatter={(value: string) =>
|
||||
value.length > 26 ? `${value.slice(0, 25)}…` : value
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTip />} cursor={{ fill: 'rgba(148,163,184,0.12)' }} />
|
||||
<Bar dataKey="count" name="blocked" fill={BLOCKED} radius={[0, 3, 3, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recent blocks"
|
||||
subtitle={recent.length > 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'}
|
||||
>
|
||||
<CardBody>
|
||||
{recentSource.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Nothing blocked yet"
|
||||
description="No blocked queries in the selected range."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{recentSource.map((e, i) => (
|
||||
<li key={`${e.name}-${i}`} className="flex items-center gap-3 px-4 py-2">
|
||||
<Badge tone="warn">{e.action || 'block'}</Badge>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{e.name}</span>
|
||||
{e.list ? (
|
||||
<span className="hidden truncate text-xs text-slate-500 sm:inline dark:text-slate-400">
|
||||
{e.list}
|
||||
</span>
|
||||
) : null}
|
||||
{e.ts ? (
|
||||
<span className="font-mono text-xs text-slate-400">{formatClock(e.ts)}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
|
||||
<div className="xl:col-span-2">
|
||||
<Card
|
||||
title="Upstreams"
|
||||
subtitle="Health, latency relative to the slowest server, and fail rate"
|
||||
actions={<HealthPill health={d.health ?? 'down'} />}
|
||||
>
|
||||
<CardBody>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={upstreamColumns}
|
||||
rows={upstreamRows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
empty="No upstreams are configured."
|
||||
loading={loading && upstreams.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Top queried" subtitle="Most requested names in range">
|
||||
<CardBody>
|
||||
{topQueried.length === 0 ? (
|
||||
<EmptyState title="No query data" description="Nothing was resolved in this range." />
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{topQueried.slice(0, 15).map((q) => (
|
||||
<li key={`${q.domain}-${q.qtype}`} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<Badge tone={q.blocked ? 'warn' : 'default'}>
|
||||
{q.qtype || '?'}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{q.domain}</span>
|
||||
<span className="font-mono text-xs text-slate-500">{formatNumber(q.count)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Domains — the inline rules that override the downloaded lists.
|
||||
*
|
||||
* The table is server-paged, so every filter change rewinds to page 1 and the
|
||||
* query object is memoised so `useApi` only refetches when something the
|
||||
* server cares about actually changed.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { addDomain, deleteDomain, getDomains } from '../../lib/api'
|
||||
import type { AddDomainInput } from '../../lib/api'
|
||||
import type { DomainItem } from '../../lib/types'
|
||||
import { useApi, useAsyncAction } from '../../hooks/useApi'
|
||||
import { useDebounced } from '../../hooks/useDebounced'
|
||||
import { formatNumber, formatRelative, toLines } from '../../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Textarea,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
}
|
||||
|
||||
const PER_PAGE = 50
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: 'block', label: 'Block (NXDOMAIN)' },
|
||||
{ value: 'allow', label: 'Allow (pass through)' },
|
||||
{ value: 'drop', label: 'Drop (no response)' },
|
||||
{ value: 'nxdomain', label: 'NXDOMAIN' },
|
||||
{ value: 'redirect', label: 'Redirect' },
|
||||
]
|
||||
|
||||
const NAME_HINT =
|
||||
'*.example.com matches subdomains, **.example.com matches the apex too, a bare name matches exactly.'
|
||||
|
||||
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
|
||||
switch (action) {
|
||||
case 'allow':
|
||||
return 'ok'
|
||||
case 'block':
|
||||
return 'warn'
|
||||
case 'drop':
|
||||
return 'danger'
|
||||
case 'nxdomain':
|
||||
return 'warn'
|
||||
case 'redirect':
|
||||
return 'info'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
type AddForm = {
|
||||
name: string
|
||||
action: string
|
||||
list: string
|
||||
bulk: string
|
||||
advanced: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
|
||||
|
||||
export default function Domains() {
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [list, setList] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const q = useDebounced(search, 300)
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [form, setForm] = useState<AddForm>(EMPTY_FORM)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<DomainItem | null>(null)
|
||||
|
||||
// A new filter makes the old page number meaningless.
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [q, action, list])
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
q: q.trim(),
|
||||
action: action === '' ? undefined : action,
|
||||
list: list === '' ? undefined : list,
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
}),
|
||||
[q, action, list, page],
|
||||
)
|
||||
|
||||
const { data, error, loading, refetch } = useApi(() => getDomains(filters), [filters])
|
||||
|
||||
const items: DomainItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const total = data?.total ?? items.length
|
||||
|
||||
const add = useAsyncAction(async (input: AddDomainInput) => addDomain(input))
|
||||
const remove = useAsyncAction(async (name: string, act: string, src: string) =>
|
||||
deleteDomain(name, act, src),
|
||||
)
|
||||
|
||||
const lists = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const item of items) {
|
||||
if (item.list) seen.add(item.list)
|
||||
if (item.source) seen.add(item.source)
|
||||
}
|
||||
return Array.from(seen).sort()
|
||||
}, [items])
|
||||
|
||||
const bulkLines = useMemo(() => toLines(form.bulk), [form.bulk])
|
||||
const canSubmit =
|
||||
form.action !== '' && (form.bulk.trim() !== '' || form.name.trim() !== '')
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setFormError(null)
|
||||
if (!canSubmit) {
|
||||
setFormError('enter a domain, or paste rules in the bulk box')
|
||||
return
|
||||
}
|
||||
if (form.bulk.trim() !== '') {
|
||||
for (const line of bulkLines) {
|
||||
const allow = line.startsWith('!')
|
||||
const ruleName = allow ? line.slice(1).trim() : line
|
||||
if (ruleName === '') continue
|
||||
const res = await add.run({
|
||||
name: ruleName,
|
||||
action: allow ? 'allow' : form.action,
|
||||
list: form.list.trim() || undefined,
|
||||
source: 'console',
|
||||
content: '',
|
||||
})
|
||||
if (res === null) {
|
||||
notify(toast, 'fail', add.error ?? `could not add ${ruleName}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
notify(toast, 'ok', `added ${bulkLines.length} rule${bulkLines.length === 1 ? '' : 's'}`)
|
||||
} else {
|
||||
const res = await add.run({
|
||||
name: form.name.trim(),
|
||||
action: form.action,
|
||||
list: form.list.trim() || undefined,
|
||||
source: 'console',
|
||||
content: '',
|
||||
})
|
||||
if (res === null) {
|
||||
setFormError(add.error ?? 'the server rejected that rule')
|
||||
notify(toast, 'fail', add.error ?? 'could not add the rule')
|
||||
return
|
||||
}
|
||||
notify(toast, 'ok', `added ${form.name.trim()}`)
|
||||
}
|
||||
setForm(EMPTY_FORM)
|
||||
setAddOpen(false)
|
||||
refetch()
|
||||
}
|
||||
|
||||
const confirmDelete = async (): Promise<void> => {
|
||||
const target = pendingDelete
|
||||
if (!target) return
|
||||
const res = await remove.run(target.name, String(target.action), target.list || target.source)
|
||||
if (res === null) {
|
||||
notify(toast, 'fail', remove.error ?? `could not delete ${target.name}`)
|
||||
return
|
||||
}
|
||||
setPendingDelete(null)
|
||||
notify(toast, 'ok', `deleted ${target.name}`)
|
||||
refetch()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', header: 'Domain', width: '40%' },
|
||||
{ key: 'action', header: 'Action', width: '16%' },
|
||||
{ key: 'source', header: 'List', width: '20%' },
|
||||
{ key: 'hits', header: 'Hits', width: '10%', align: 'right' as const },
|
||||
{ key: 'updated', header: 'Updated', width: '10%' },
|
||||
{ key: 'delete', header: '', width: '4%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const rows: Row[] = items.map((item, i) => ({
|
||||
__key: `${item.name}-${item.action}-${item.list}-${i}`,
|
||||
name: <span className="block max-w-[320px] truncate font-mono text-xs">{item.name}</span>,
|
||||
action: <Badge tone={actionTone(String(item.action))}>{item.action}</Badge>,
|
||||
source: (
|
||||
<span className="block max-w-[200px] truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{item.list || item.source || '—'}
|
||||
</span>
|
||||
),
|
||||
hits: <span className="font-mono text-xs">{formatNumber(item.hits)}</span>,
|
||||
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
|
||||
delete: (
|
||||
<IconButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label={`delete ${item.name}`}
|
||||
variant="ghost"
|
||||
onClick={() => setPendingDelete(item)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Domains"
|
||||
description="Inline allow and block rules, checked before the downloaded lists."
|
||||
actions={
|
||||
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}>
|
||||
Add rule
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Input
|
||||
label="Search"
|
||||
placeholder="ads.example.com"
|
||||
icon={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
options={[{ value: '', label: 'All actions' }, ...ACTIONS.map((a) => ({ value: a.value, label: a.label }))]}
|
||||
/>
|
||||
<Select
|
||||
label="List"
|
||||
value={list}
|
||||
onChange={(e) => setList(e.target.value)}
|
||||
options={[{ value: '', label: 'All lists' }, ...lists.map((l) => ({ value: l, label: l }))]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Rules"
|
||||
subtitle={`${formatNumber(total)} matching`}
|
||||
padded={false}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={loading}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No rules match"
|
||||
description="Change the filters, or add a rule with the button above."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 p-3 dark:border-slate-800">
|
||||
<Pagination page={page} perPage={PER_PAGE} total={total} onPage={setPage} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
title="Add a rule"
|
||||
description="Rules are checked before the blocklists, so an allow rule wins over a block."
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setAddOpen(false)} disabled={add.pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void submit()} loading={add.pending} disabled={!canSubmit}>
|
||||
{bulkLines.length > 0 ? `Add ${bulkLines.length} rules` : 'Add rule'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{formError ? (
|
||||
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
<Input
|
||||
label="Domain"
|
||||
placeholder="ads.example.com"
|
||||
hint={NAME_HINT}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
disabled={form.advanced}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Action"
|
||||
value={form.action}
|
||||
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
|
||||
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
|
||||
/>
|
||||
<Input
|
||||
label="List"
|
||||
placeholder="inline"
|
||||
hint="Optional group name, defaults to the inline list."
|
||||
value={form.list}
|
||||
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400"
|
||||
onClick={() => setForm((f) => ({ ...f, advanced: !f.advanced }))}
|
||||
>
|
||||
{form.advanced ? 'Hide bulk paste' : 'Bulk paste'}
|
||||
</button>
|
||||
{form.advanced ? (
|
||||
<Textarea
|
||||
label="Rules"
|
||||
hint={`One per line; a leading ! makes it an allow rule. ${bulkLines.length} line${bulkLines.length === 1 ? '' : 's'} parsed.`}
|
||||
monospace
|
||||
rows={8}
|
||||
value={form.bulk}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bulk: e.target.value }))}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
title="Delete this rule?"
|
||||
message={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={remove.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user