diff --git a/README.md b/README.md index e69de29..bec3f7e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,787 @@ +# fancy-dns + +A recursive DNS server with blocklists, per-client ACLs, a query log, an +embedded admin console and optional clustering over Valkey/Redis. + +* **What it is** — a single Go binary that answers DNS over UDP, TCP, + DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484), resolves everything + else from a pool of upstream recursors, filters it through blocklists and + client ACLs, caches the answers, logs the queries, and serves an admin REST + API plus a React console from the same process. + +## Features + +Each line points at the package that implements it. + +| Feature | Where | +|---|---| +| UDP, TCP, DoT and DoH listeners, all funnelling into one handler | `internal/server` | +| DoH with the RFC 8484 `GET ?dns=` and `POST application/dns-message` profiles over h2, with an h2c fallback; `/.well-known/dns-query` alias; `/healthz` on the same listener | `internal/server/server.go` | +| DoT with optional mTLS (`client_ca`) and configurable `min_version` | `internal/server/server.go` | +| One query pipeline: `rate limit → ACL → local zone → blocklist → cache → upstream` | `internal/engine` | +| Sharded LRU answer cache over packed wire bytes, TTL clamping, serve-stale, prefetch, bbolt snapshot for warm restarts | `internal/cache` | +| Upstream pool with `parallel` / `random` / `failover` policies, weighted selection, per-upstream circuit breakers, health probes, bootstrap IPs for TLS upstreams | `internal/resolver` | +| Blocklists from hosts, Adblock Plus, plain and RPZ sources, compiled into one immutable set that is swapped atomically | `internal/blocklist` | +| Authoritative local data and RPZ policy zones from RFC 1035 master files | `internal/zonefile`, `internal/localzone` | +| Per-client ACL groups matched on CIDR, PTR suffix or reverse-name tag, with per-group domain policy, rate limit, concurrency cap, cache and blocklist toggles | `internal/acl` | +| Query log in a bbolt ring plus an in-memory live ring, sampling, per-domain counters and an SSE stream | `internal/qlog` | +| Prometheus metrics (`fancydns_*`) | `internal/metrics` | +| Admin REST API with API-key / basic / no auth, read-only keys, CORS, per-IP rate limiting and login lockout | `internal/api` | +| Admin console embedded in the binary with `go:embed` | `internal/web`, `web/` | +| Hot reload of the config file (SIGHUP, API, or a cluster peer) with every component swapped atomically | `internal/config`, `internal/app` | +| Cluster coordination over Valkey/Redis: pub/sub events, heartbeats, blocklist-digest divergence detection and a single list-fetch leader lease | `internal/cluster` | +| YAML config with `${VAR}` / `${VAR:-default}` expansion, hot-reloadable atomic store, aggregated validation errors | `internal/config` | + +## Quick start + +Requirements: Go 1.27 (the `go` directive in `go.mod`), Node 22 for the console +build, and a privileged port (53/443/853) or `CAP_NET_BIND_SERVICE` for the +default listener set. + +```sh +git clone fancy-dns && cd fancy-dns +make build # builds the console into internal/web/dist, then dist/fancy-dns +``` + +`make build` depends on `make web`, which runs `npm ci` (if `web/node_modules` +is missing) and `npm run build` — the Vite build writes to +`internal/web/dist`, which is what `//go:embed all:dist` in +`internal/web/web.go` reads. On a fresh clone that has to happen before the +first `go build` of a console-serving binary. + +A minimal `config.yaml`. It listens on unprivileged ports so it can be run +without privileges, and it uses a local plain list so the first start needs no +network: + +```yaml +server: + max_concurrent: 512 + +dns: + udp: ["127.0.0.1:5353"] + tcp: ["127.0.0.1:5353"] + max_udp_size: 1232 + +upstream: + policy: parallel + servers: + - addr: 9.9.9.9:53 + proto: udp + +cache: + enabled: true + size: 10000 + persist: + enabled: false + +blocklist: + enabled: true + default_action: nxdomain + lists: + - name: local + format: plain + path: lists/blocked.txt + enabled: true # lists are OFF unless you say enabled: true + refresh: 0s # 0 disables the scheduled refresh + +qlog: + enabled: true + path: data/queries.db + +api: + enabled: true + listen: ["127.0.0.1:8080"] + base_path: /api + ui: true + auth: + mode: apikey + api_keys: ["CHANGE-ME-long-random-string"] + trust_loopback: true +``` + +```sh +printf '# one domain per line\nads.example.com\ntracker.example.net\n' > lists/blocked.txt + +./dist/fancy-dns -config config.yaml -check # validate and exit +./dist/fancy-dns -config config.yaml # run (SIGHUP reloads, SIGINT/SIGTERM stops) + +dig @127.0.0.1 -p 5353 example.com A # NOERROR, resolved upstream +dig @127.0.0.1 -p 5353 ads.example.com A # NXDOMAIN + synthetic SOA +dig @127.0.0.1 -p 5353 sub.ads.example.com A # NXDOMAIN (subdomains are covered too) + +curl -s http://127.0.0.1:8080/api/healthz # unauthenticated probe +curl -s -H 'X-API-Key: CHANGE-ME-long-random-string' http://127.0.0.1:8080/api/whoami +``` + +The console is at and the metrics at +. + +### Command line + +| Flag | Default | Meaning | +|---|---|---| +| `-config PATH` | `$FANCYDNS_CONFIG`, else `/etc/fancy-dns/config.yaml` | config file | +| `-version` | off | print the version and exit | +| `-check` | off | validate the config and exit | +| `-log-level LEVEL` | `$FANCYDNS_LOG_LEVEL` | override `log.level` (`debug`, `info`, `warn`, `error`) | +| `-log-format FORMAT` | `$FANCYDNS_LOG_FORMAT` | override `log.format` (`text`, `json`) | +| `-admin-addr ADDR` | `$FANCYDNS_ADMIN_ADDR` | override `api.listen` without touching the config file | + +Signals: `SIGHUP` re-reads the config file and hot-swaps every component; a +failed reload is logged and the running configuration keeps serving. `SIGINT` +and `SIGTERM` start a graceful shutdown with a 15 s drain +(`cmd/fancy-dns/main.go`). + +Environment expansion: before parsing, `config.Load` expands `${VAR}`, +`${VAR:-default}` and `${VAR-default}` outside YAML comments. An unset variable +with no default is a **startup error** — that is what makes a missing Kubernetes +Secret a crash loop instead of an unauthenticated API. + +## Configuration reference + +The document is unmarshalled on top of `config.Default()`, so every key is +optional. Durations accept Go duration strings (`"30s"`, `"1h30m"`) or a bare +number, which is seconds. Relative paths resolve against the directory of the +config file (`Config.AbsPath`). Validation is aggregated: `-check` and +`POST /api/settings/validate` report every problem at once. + +Keys marked **unused** are parsed and validated but not read by any code path +today (see [Known inconsistencies](#known-inconsistencies)). + +### server + +| Key | Type | Default | What it does | +|---|---|---|---| +| `version` | int | `0` | bumped by the admin API on every write; echoed in `/api/settings` and `/api/status` | +| `server.max_concurrent` | int | `1024` | in-flight upstream resolutions; excess queries get SERVFAIL instead of queueing | +| `server.max_concurrent_per_client` | int | `0` (off) | **unused** — use `acl.groups[].max_concurrent` | +| `server.timeouts.udp` | duration | `2s` | not enforced as named: the UDP loop uses a fixed 500 ms read tick and a 2 s write deadline | +| `server.timeouts.tcp` | duration | `4s` | not enforced as named; the TCP/DoT idle timeout is `timeouts.tls` | +| `server.timeouts.tls` | duration | `4s` | DoT/DoH connection idle timeout | +| `server.timeouts.query` | duration | `5s` | **unused** | +| `server.timeouts.upstream` | duration | `2s` | bounds one upstream exchange (including the RFC 1035 retry over TCP) | +| `server.timeouts.list_fetch` | duration | `60s` | seeds `blocklist.lists[].timeout`; **unused** at fetch time (the list HTTP client uses a fixed 120 s) | +| `server.working_dir` | string | config file's directory | base for relative paths; shown in the UI | + +### dns + +| Key | Type | Default | What it does | +|---|---|---|---| +| `dns.udp` | list of `host:port` | `["0.0.0.0:53", "[::]:53"]` | plain DNS over UDP | +| `dns.tcp` | list of `host:port` | `["0.0.0.0:53", "[::]:53"]` | plain DNS over TCP (length-prefixed, pipelining allowed) | +| `dns.max_udp_size` | int (512–4096) | `1232` | advertised EDNS0 buffer size for our own answers and upstream queries | +| `dns.serve_stale` | duration | `0s` | **unused** — serve-stale is configured per cache (`cache.serve_stale`) | +| `dns.recursion_desired` | bool | `true` | **unused** — RD is always set on outbound queries | +| `dns.hide_identity` | bool | `false` | drop the `fancy-dns` EDNS0 NSID and the `Server: fancy-dns/` header on DoH | +| `dns.dot.enabled` | bool | `false` | enable the DoT listener | +| `dns.dot.listen` | list of `host:port` | `["0.0.0.0:853"]` | DoT bind addresses | +| `dns.dot.cert_file` / `key_file` | path | — | required when `enabled`; both empty means the listener starts but serves plaintext, one empty is a start error | +| `dns.dot.client_ca` | path | — | enables mTLS (`RequireAndVerifyClientCert`) | +| `dns.dot.min_version` | `"1.2"` \| `"1.3"` \| `"1.0"` | `"1.2"` | anything else means TLS 1.2 | +| `dns.dot.upstream` | bool | `false` | **unused** | +| `dns.doh.enabled` | bool | `false` | enable the DoH listener | +| `dns.doh.listen` | list of `host:port` | `["0.0.0.0:443"]` | DoH bind addresses | +| `dns.doh.cert_file` / `key_file` | path | — | without both, the listener logs "running without TLS" and serves h2c | +| `dns.doh.base_path` | path | `/dns-query` | RFC 8484 endpoint; `/.well-known/dns-query` is also served when it differs | +| `dns.doh.allow_post` | bool | `true` | enable the `POST application/dns-message` profile | +| `dns.doh.min_version` | string | `"1.2"` | as `dns.dot.min_version` | +| `dns.doh.transport.mode` | `"auto"` | `"auto"` | **unused** — the listener is always h2 with an h2c fallback; there is no HTTP/3 | +| `dns.doh.transport.alt_svc` | string | — | **unused** | +| `dns.edns0.client_subnet` | bool | `true` | **unused** — ECS is forwarded whenever `preserve` is on | +| `dns.edns0.ipv4_bits` / `ipv6_bits` | int | `24` / `56` | **unused** — the client's ECS option is forwarded verbatim | +| `dns.edns0.cookie_support` | bool | `false` | **unused** — cookies are deliberately not forwarded | +| `dns.edns0.preserve` | bool | `true` | copy the client's ECS and padding into the upstream query | + +### upstream + +| Key | Type | Default | What it does | +|---|---|---|---| +| `upstream.policy` | `parallel` \| `random` \| `failover` | `parallel` | `parallel` races every chosen upstream and takes the first non-SERVFAIL; `random` shuffles and tries up to `max_attempts` in order; `failover` walks the list in order | +| `upstream.servers` | list | Quad9 ×2 (UDP + DoT), Cloudflare ×2 | the pool; must not be empty | +| `upstream.servers[].addr` | `host:port` | — | required; a bare host gets `:53`, or `:853` for `proto: tls` | +| `upstream.servers[].proto` | `udp` \| `tcp` \| `tls` | `udp` | `tls` is DoT with SNI from `server_name` | +| `upstream.servers[].server_name` | string | host part of `addr` | SNI / TLS verification name for `proto: tls` | +| `upstream.servers[].insecure_skip_verify` | bool | `false` | disables upstream certificate validation | +| `upstream.servers[].health_name` | string | — | **unused**; health probes always ask the dialled address for `. NS` | +| `upstream.servers[].weight` | int | `1` | biases the `random` shuffle | +| `upstream.servers[].max_fails` | int | `0` (use global) | per-upstream breaker threshold | +| `upstream.servers[].comment` | string | — | free text shown in the UI | +| `upstream.max_attempts` | int | `3` | how many upstreams one query may try | +| `upstream.healthcheck_interval` | duration | `30s` | active probe interval (`0` disables it) | +| `upstream.breaker_threshold` | int | `5` | consecutive failures that open the breaker | +| `upstream.breaker_cooldown` | duration | `30s` | how long the breaker stays open; a success closes it early | +| `upstream.bootstraps` | list | none | `name` → `addresses`, used when `addr`'s host is a name; the first address is dialled and the name stays for SNI | +| `upstream.bootstraps[].refresh` | duration | — | **unused** | + +### cache + +| Key | Type | Default | What it does | +|---|---|---|---| +| `cache.enabled` | bool | `true` | master switch | +| `cache.size` | int (entries) | `200000` | LRU entry budget | +| `cache.shards` | int | `32` | rounded **up** to a power of two | +| `cache.min_ttl` / `cache.max_ttl` | duration | `0s` / `1h` | clamp for TTLs taken from upstream answers | +| `cache.serve_stale` | duration | `30m` | how long an expired entry stays answerable (served with TTL 1) | +| `cache.prefetch` | bool | `true` | re-resolve stale entries in the background | +| `cache.negative_ttl` | duration | `30m` | bounds cached NXDOMAIN / NODATA | +| `cache.prewarm` | list of `"name/TYPE"` | empty | **unused** (`App.prewarm` exists but is never called) | +| `cache.persist.enabled` | bool | `true` | write a bbolt snapshot | +| `cache.persist.path` | path | `data/cache.db` | snapshot file | +| `cache.persist.interval` | duration | `30s` | snapshot cadence (also written on shutdown) | +| `cache.persist.max_entries` | int | `500000` | cap on the snapshot | +| `cache.persist.load_on_start` | bool | `true` | restore the snapshot at boot; entries past `ExpiresAt` are dropped and the rest get the TTL they have left | + +Snapshot record format (`internal/cache/store.go`): `byte 0` format version, +`byte 1` flags (`neg`, `truncated`), then uvarints for `ExpiresAt`, +`StaleUntil` and `CreatedAt` (Unix nanoseconds, 0 = zero time), `TTL` seconds, +length-prefixed `Origin` and `Upstream`, the packed wire answer, and a +little-endian CRC-32C trailer. One damaged record costs one entry, not the +file. + +### blocklist + +| Key | Type | Default | What it does | +|---|---|---|---| +| `blocklist.enabled` | bool | `true` | when on, at least one of `lists`, `local_zones`, `block_entries`, `allow_entries` must be set or `Validate` fails | +| `blocklist.default_action` | `nxdomain` \| `zero` \| `redirect` \| `drop` \| `refused` | `nxdomain` | the synthesised answer for a blocked name | +| `blocklist.response_ip` / `response_ipv6` | IP | `0.0.0.0` / `::` | used by `zero`; the family is chosen from the question | +| `blocklist.response_ttl` | duration | `60s` | TTL of synthesised answers | +| `blocklist.redirect_name` | name | — | required when `default_action: redirect` | +| `blocklist.local_zones` | list of globs | empty | RPZ policy zones and/or local authoritative data, read from master files | +| `blocklist.block_entries` / `allow_entries` | list of lines | empty | inline rules, compiled as `inline:block` / `inline:allow` and applied before every list | +| `blocklist.parallelism` | int | `4` | **unused** — compilation is single-threaded | +| `blocklist.lists[].name` | string | — | required, unique | +| `blocklist.lists[].format` | `auto` \| `hosts` \| `adblock` \| `plain` \| `rpz` | `auto` | see [Blocklists](#blocklists) | +| `blocklist.lists[].url` | http(s):// or file:// | — | one of `url` or `path` is required; `url` wins and its result is written to `path` so restarts work offline | +| `blocklist.lists[].path` | path | — | on-disk copy, resolved against the config directory | +| `blocklist.lists[].mirror` | list of URLs | empty | tried in order when the primary URL fails | +| `blocklist.lists[].enabled` | bool | **`false`** | a list is skipped entirely unless this is `true` | +| `blocklist.lists[].refresh` | duration | `24h` | the scheduler ticks every 5 minutes, so a source is refreshed within 5 minutes of its interval; `0` disables scheduled refresh (manual `/api/blocklists/refresh` only) | +| `blocklist.lists[].timeout` | duration | `server.timeouts.list_fetch` | **unused** at fetch time | +| `blocklist.lists[].max_bytes` | int | `536870912` (512 MiB) | refuses a list larger than this | +| `blocklist.lists[].etag` / `last_modified` | string | — | sent as `If-None-Match` / `If-Modified-Since`; a 304 keeps the previous content | +| `blocklist.lists[].categories` | list of strings | empty | tags copied onto every rule from this source; they feed the UI breakdown | +| `blocklist.lists[].insecure_skip_tls_verify` | bool | `false` | for a self-hosted mirror with a private CA; also stops the download being persisted | +| `blocklist.lists[].comment` | string | — | free text | + +### acl + +| Key | Type | Default | What it does | +|---|---|---|---| +| `acl.default_policy` | `allow` \| `deny` \| `block_only` | `allow` | applied when no group matches | +| `acl.default_rate_limit` | int (queries/s per client) | `0` (unlimited) | fixed-window cap | +| `acl.groups` | list | empty | evaluated top to bottom, first match wins | +| `acl.groups[].name` / `comment` | string | — | `name` required and unique | +| `acl.groups[].match.cidrs` | list of CIDR | empty | matched against the client address (v4 or v6) | +| `acl.groups[].match.ptr_suffixes` | list | empty | matched against reverse-DNS names of the client, on a label boundary; a missing trailing dot is added | +| `acl.groups[].match.tags` | list | empty | labels derived from the reverse name (e.g. `laptop` for `laptop.lan.internal.`) | +| `acl.groups[].match.any` | bool | `false` | catch-all; must be the last group | +| `acl.groups[].policy` | `allow` \| `deny` \| `block_only` | `allow` | `deny` answers REFUSED and wins over the local zone | +| `acl.groups[].allow_domains` / `deny_domains` | list | empty | per-group name policy with the blocklist wildcards; `deny_domains` is checked first | +| `acl.groups[].skip_blocklist` | bool | `false` | the group opts out of blocklist filtering | +| `acl.groups[].skip_cache` | bool | `false` | the group only gets fresh lookups | +| `acl.groups[].rate_limit` | int | inherit | queries/second for this group | +| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client | +| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `qlog.sample_rate` | +| `acl.groups[].upstreams` | list of addrs | empty | restrict this group to named upstreams; a list that matches nothing falls back to the whole pool | + +### qlog + +| Key | Type | Default | What it does | +|---|---|---|---| +| `qlog.enabled` | bool | `true` | when off the bbolt file is not opened (the live ring and counters stay) | +| `qlog.path` | path | `data/queries.db` | bbolt ring file | +| `qlog.max_entries` | int | `200000` | ring bound; the oldest entries are dropped | +| `qlog.flush_interval` | duration | `1s` | batches writes onto a background goroutine | +| `qlog.sample_rate` | 0–1 | `1.0` | sampling for ordinary queries; blocked/error/cached logging is governed by the flags below | +| `qlog.log_blocked` | bool | `true` | always log blocks | +| `qlog.log_cached` | bool | `true` | log cache hits (set `false` on a busy instance) | +| `qlog.log_errors` | bool | `true` | always log SERVFAIL and friends | +| `qlog.log_client_names` | bool | `false` | **unused** | +| `qlog.live_buffer` | int | `2000` | in-memory ring that feeds the UI and the SSE stream | +| `qlog.top_domains` | int | `5000` | number of per-domain counters kept | +| `qlog.top_ttl` | duration | `5m` | how long those counters are aggregated | + +### api + +| Key | Type | Default | What it does | +|---|---|---|---| +| `api.enabled` | bool | `true` | master switch for the admin listener | +| `api.listen` | list of `host:port` | `["127.0.0.1:8080"]` | plaintext admin listener (also serves the console) | +| `api.tls_listen` | list of `host:port` | empty | extra listeners with TLS | +| `api.cert_file` / `key_file` | path | — | required for `tls_listen`; h2 + http/1.1, TLS 1.2 minimum | +| `api.base_path` | path | `/api` | prefix for every route; the console is served on the rest | +| `api.ui` | bool | `true` | serve the embedded console | +| `api.allow_config_write` | bool | `true` | lets `PUT /api/settings` rewrite the config file | +| `api.allow_blocklist_write` | bool | `true` | **unused** | +| `api.trusted_proxies` | list of CIDR | empty | the only peers whose `X-Forwarded-For` / `X-Real-IP` is believed | +| `api.cors_origins` | list | empty | `"*"` is allowed but discouraged; the origin is echoed per request so credentials keep working | +| `api.auth.mode` | `none` \| `apikey` \| `basic` | `apikey` | see [Admin API](#admin-api) | +| `api.auth.api_keys` | list | empty | full-access keys (`X-API-Key` header, or basic auth with any user) | +| `api.auth.read_only_api_keys` | list | empty | GET-only keys; mutations answer 403 | +| `api.auth.username` / `password` | string | — | for `mode: basic`; the password may be plaintext or a bcrypt hash (it is hashed either way and compared in constant time) | +| `api.auth.session_ttl` | duration | `12h` | lifetime of a `POST /api/auth/login` bearer token | +| `api.auth.trust_loopback` | bool | `true` | requests from `127.0.0.0/8` and `::1` authenticate as a local principal without a key | +| `api.rate_limit.per_minute` | int | `60` | requests per minute per client IP; `0` disables | +| `api.rate_limit.failures` | int | `10` | failed logins per minute per IP before a lockout | +| `api.rate_limit.lockout` | duration | `5m` | lockout length; 429 carries `Retry-After` | + +### metrics + +| Key | Type | Default | What it does | +|---|---|---|---| +| `metrics.enabled` | bool | `true` | registers the route | +| `metrics.path` | path | `/metrics` | **unused** — the endpoint is always `${api.base_path}/metrics` | +| `metrics.protect` | bool | `false` | require a principal for `/api/metrics` | + +Metric names: `fancydns_queries_total`, `fancydns_blocked_total`, +`fancydns_cache_hits_total`, `fancydns_cache_misses_total`, +`fancydns_cache_stale_hits_total`, `fancydns_upstream_errors_total`, +`fancydns_errors_total`, `fancydns_dropped_total`, `fancydns_refused_total`, +`fancydns_upstream_health`, `fancydns_concurrent_queries`, +`fancydns_blocklist_rules`, `fancydns_blocklist_source_bytes`, +`fancydns_acl_groups`, `fancydns_local_records`, `fancydns_up`, +`fancydns_query_duration_seconds`, `fancydns_upstream_duration_seconds`, +`fancydns_blocklist_compile_seconds`. + +### log + +| Key | Type | Default | What it does | +|---|---|---|---| +| `log.level` | `debug` \| `info` \| `warn` \| `error` | `info` | overridden by `-log-level` | +| `log.format` | `text` \| `json` | `text` | overridden by `-log-format` | +| `log.addr` | path | empty (stderr) | when it starts with `/` the log is appended to that file; any other value is ignored | + +### cluster + +| Key | Type | Default | What it does | +|---|---|---|---| +| `cluster.enabled` | bool | `false` | when on, `cluster.valkey.addrs` must not be empty | +| `cluster.node_id` | string | hostname | identity in the cluster view, suffixed with a random 4-byte id | +| `cluster.advertise` | string | empty | published in the heartbeat; **nothing dials it** today | +| `cluster.peer_sync` | bool | `false` | **unused** | +| `cluster.peer_ttl` | duration | `15s` | the heartbeat period is half this; the node hash expires after 4× | +| `cluster.valkey.addrs` | list of `host:port` | — | Valkey/Redis endpoints (joined with commas into one client) | +| `cluster.valkey.username` / `password` / `db` | string/int | — | ACL-based auth, DB index | +| `cluster.valkey.prefix` | string | `fancydns` | key prefix: `:nodes`, `:leader`, `:events:` | +| `cluster.valkey.tls` | bool | `false` | **unused** — no TLS is configured on the client | +| `cluster.valkey.pool_size` | int | `10` | minimum 1 | +| `cluster.valkey.dial_timeout` / `read_timeout` | duration | `2s` | client timeouts; `read_timeout` is also the write timeout | +| `cluster.valkey.leader_ttl` | duration | `30s` | length of the list-refresh lease | + +## Zone files and local zones + +`internal/zonefile` parses an RFC 1035 master-file subset: + +* `$TTL ` sets the default TTL, `$ORIGIN ` changes the origin. +* Parenthesised continuations are folded into one logical line; `;` starts a + comment outside quoted strings; a leading UTF-8 BOM is stripped. +* A record may omit the owner (it repeats the previous one), the TTL and the + class, in any order, as long as the type token is recognisable. TTLs accept + the `s`/`m`/`h`/`d`/`w` unit suffixes or bare seconds. +* `@` means the current origin; a name that ends in a dot is absolute and is + never origin-relative. +* `$INCLUDE` is **rejected**: `internal/zonefile/zonefile.go` returns + "`$INCLUDE … is not supported; use blocklist.local_zones or a list source + instead`". Split the data into more `local_zones` globs instead. +* `zonefile.SerialNext` implements RFC 1982 arithmetic (`+N`, `-N`, absolute), + but there is no zone transfer, so nothing calls it. + +**Zone name from a path.** `localzone.Load` derives each file's origin with +`zonefile.OriginFromPath`, which takes the base name and truncates it at the +first dot: `zones/lan.zone` and `lan.zone` both become the origin `lan.` +(the doc comment on that function says `ads.zone.`, which is wrong — see +[Known inconsistencies](#known-inconsistencies)). An explicit `$ORIGIN` inside +the file wins, because `Parse` starts from that derived origin and the +directive replaces it. Records that fall outside their own zone are dropped +(`localzone.add` returns false). + +**Policy zone vs ordinary local zone.** Both come from +`blocklist.local_zones` and both are master files; the difference is what the +records mean and where they are matched: + +| | RPZ-style policy zone | Ordinary local zone | +|---|---|---| +| Compiled by | `blocklist.parseRPZ` / `parseRPZFiles` | `localzone.Load` | +| Matching | by rule name; `*.name` covers subdomains; the rdata carries the action | by the question: exact `name\|TYPE`, then `*.`, then any type at the name | +| Action from rdata | `CNAME .` → NXDOMAIN, `CNAME rpz-drop.` → drop, `CNAME rpz-passthru.` / `rpz-tcp-only.` → passthrough, other CNAME → CNAME answer, `A`/`AAAA` → that address, `TXT` containing `allow` → allow | none; records are answers, and the zone's SOA is returned for NXDOMAIN/NODATA | +| In the pipeline | after the ACL, before the cache | after the ACL, before the blocklist | +| Delegation | n/a | an `NS` record below the apex marks a delegation and the data below it is not answered | + +`deploy/local.example.zone` is a local authoritative zone (SOA, NS, A/AAAA, +one-label and deeper wildcards, SRV, TXT). `deploy/zones/local.rpz.zone.example` +is a policy zone. + +## Blocklists + +A source is compiled into rules, and every source is merged into one immutable +`Set`; the `Manager` swaps a whole new set in with one atomic store, so a query +sees either the old rules or the new ones. Matching is a few map lookups +against an exact map, a subdomain map and a wildcard map. + +Name patterns: `example.com` matches the name itself, `*.example.com` matches +strict subdomains, `**.example.com` matches the apex and everything under it. +Sources are parsed with subdomains on by default, so a bare hosts-file or +plain-list entry becomes `**.name`. + +| `format` | Accepts | Example | Notes | +|---|---|---|---| +| `hosts` | `IP name [name…]`, `0 name`, `localhost` lines, `#`/`;` comments | `0.0.0.0 ads.example.com` | `localhost`/`broadcasthost`/`ip6-*` lines are skipped; a line whose first field is not an address falls back to the plain parser, with a notice in the parse stats | +| `adblock` | `\|\|host^` and `\|http://host/…` (blocked), `@@\|\|host^` (allowed), bare hostnames | `\|\|ads.example.com^$third-party` | element-hiding rules (`##`, `#@#`, `#?#`) and regex rules are skipped and reported in `stats.notices`; a URL path rule blocks the whole host | +| `plain` | one name per line, `0.0.0.0 name`, `!allow name` / `!name` / `@@…` allow markers, adblock tails on pasted lines | `ads.example.com` | a single-label name without a dot is rejected | +| `rpz` | an RFC 1035 policy zone | `ads.example.com CNAME .` | parsed by `internal/zonefile`; the zone's own default TTL wins for records with no TTL column | +| `auto` | sniffs the first 200 meaningful lines | — | default; a `$TTL`/`$ORIGIN`/`$INCLUDE` line or a `name TTL IN A …` shape means `rpz`, an address-first line means `hosts`, `\|\|`/`##`/`@@` means `adblock`, otherwise `plain` | + +**Categories.** `blocklist.lists[].categories` are copied onto every rule from +that source and surfaced in the console's per-category breakdown. They have no +effect on matching. + +**Refresh and replication.** A list is fetched with a conditional GET +(`If-None-Match` / `If-Modified-Since`); a 304 keeps the previous content. The +downloaded copy is written to `path` (atomically) unless +`insecure_skip_tls_verify` is set, and a later start prefers that copy, so a +restart without network still has rules. A source that fails keeps its previous +compiled content and is flagged stale in the UI, so a bad deploy never leaves +the resolver with no blocklists. The scheduler ticks every 5 minutes and +refreshes every source whose interval has elapsed, and the first pass after a +start is staggered per source name. + +**Only the Valkey lease holder fetches.** With `cluster.enabled`, the lease +(`${prefix}:leader`, `SET NX` with `valkey.leader_ttl`) is acquired and renewed +by one instance. A `blocklists` event from any peer triggers a refresh **only** +in the lease holder; followers keep serving their compiled copy. Without +clustering, every instance fetches for itself. + +## Admin API + +Base path `/api` (`api.base_path`). `GET /api/healthz` is unauthenticated — it +is the liveness and readiness probe and deliberately reveals only a coarse +status (503 when `status: down`). `POST /api/auth/login` is unauthenticated by +necessity. `GET /api/whoami` is behind the same authentication as the rest of +the API; with `auth.trust_loopback: true` a request from loopback is authorised +as a local principal, and otherwise it answers 401 (the CI smoke test asserts +exactly that). `GET /api/metrics` is unauthenticated unless `metrics.protect` +is on. + +| Method | Path | Write | Purpose | +|---|---|---|---| +| GET | `/api/healthz` | — | status payload (public) | +| GET | `/api/metrics` | — | Prometheus exposition; gated by `metrics.protect` | +| POST | `/api/auth/login` | — | exchange an API key or user/password for a bearer token (public) | +| POST | `/api/auth/logout` | yes | revoke the caller's bearer token | +| GET | `/api/whoami` | — | caller's name, mode, `read_only`, `can_write` | +| GET | `/api/dashboard?range=…` | — | aggregated counters and the rolling series | +| GET | `/api/status` | — | version, uptime, config generation/last error, per-component status, listeners | +| GET | `/api/blocklists` | — | compiled state, per-source metadata, errors | +| POST | `/api/blocklists/refresh` | yes | recompile now; body `{"names":[…]}` or empty for everything | +| POST | `/api/blocklists/toggle` | yes | `{"name":…,"enabled":…}` | +| GET | `/api/domains?q=&action=&list=&page=&per_page=` | — | page the compiled rules | +| GET | `/api/domains/check?name=…` | — | explain how a name would be treated (the fastest "why isn't this blocked" tool) | +| POST | `/api/domains` | yes | add an inline rule | +| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule | +| GET | `/api/clients` | — | ACL groups and the default policy | +| PUT | `/api/clients` | yes | replace the ACL (`{"groups":[…],"default_policy":…}`); validates, writes the file, swaps, broadcasts | +| GET | `/api/upstreams` | — | pool state, health, latency, breaker | +| PUT | `/api/upstreams` | yes | replace policy/servers/bootstraps/attempts/threshold (cooldown is preserved) | +| GET | `/api/logs?q=&action=&client=&rcode=&blocked=&since=&limit=&offset=` | — | page the query log | +| GET | `/api/logs/stream` | — | Server-Sent Events: `ready`, `entry`, `stats` and `: ping` keep-alives | +| POST | `/api/logs/clear` | yes | empty the query log | +| GET | `/api/cache` | — | cache statistics | +| GET | `/api/cache/top?limit=` | — | hottest cached names | +| POST | `/api/cache/flush` | yes | drop the cache (also done after a rule change) | +| GET | `/api/settings` | — | file YAML, effective YAML, defaults YAML, writability | +| PUT | `/api/settings` | yes | validate, write (atomic, mode 0600), bump `version`, swap, broadcast; needs `api.allow_config_write` | +| POST | `/api/settings/validate` | — | validate a document without applying it; returns errors and warnings | +| POST | `/api/settings/reload` | yes | re-read the config file from disk | + +**Auth modes.** `apikey` (default): `X-API-Key` with a full or read-only key; +basic auth with the key in the password field is also accepted, and a `bearer` +token from `/api/auth/login` works everywhere. Keys are compared by SHA-256 +hash in constant time. `basic`: HTTP basic against `auth.username` / +`auth.password`; a successful login mints a bearer token valid for +`auth.session_ttl`. `none`: no credential is required — the console and every +mutation are open to anyone who can reach the port; the settings validator +emits a warning for exactly this. + +JSON request bodies are capped at 4 MiB and unknown fields are rejected, so a +typo is a 400 rather than a silent no-op. A panic in a handler becomes a 500 +and is logged. + +## Deployment + +### Container + +```sh +make image # docker build --build-arg VERSION=$(git describe) -t fancydns/fancy-dns: . +docker run --rm -p 53:53/udp -p 53:53/tcp -p 8080:8080 \ + -v fancydns-data:/var/lib/fancy-dns \ + -v "$PWD/config.yaml:/etc/fancy-dns/config.yaml:ro" \ + fancydns/fancy-dns: -config /etc/fancy-dns/config.yaml +``` + +The `Dockerfile` builds the console in a Node stage, then a static +`CGO_ENABLED=0` binary with it embedded, and ships it on +`gcr.io/distroless/static-debian12:nonroot`. It declares `VOLUME +/var/lib/fancy-dns` (cache snapshot, query log, downloaded lists) and copies +`deploy/config.example.yaml` plus `deploy/zones/` to `/etc/fancy-dns/`; there +is no `config.yaml` in the image, so its `CMD` (`-config +/etc/fancy-dns/config.yaml`) requires you to mount one. + +### Kubernetes + +`deploy/k8s/` is a kustomization: namespace, ConfigMap, ServiceAccount +(`automountServiceAccountToken: false` — the server never talks to the API +server), the API-key Secret, a StatefulSet, three Services, an Ingress and a +PodDisruptionBudget. + +```sh +kubectl -n dns create secret generic fancy-dns-apikey \ + --from-literal=api-key="$(openssl rand -hex 32)" # the API-key step +kubectl kustomize deploy/k8s | kubectl apply -f - +``` + +Things to know before you apply it: + +* **The API key comes from a Secret, not the ConfigMap.** The config contains + `api_keys: ["${FANCYDNS_API_KEY}"]` and the loader substitutes it from the + pod's environment. A missing variable is a startup error, so a forgotten + Secret is a `CrashLoopBackOff`, not an open console. The checked-in Secret + holds the placeholder `REPLACE-ME`; applying it as-is overwrites a real key, + and nothing rejects a weak key. +* **A LoadBalancer is required for DNS.** `fancy-dns-dns` is a `LoadBalancer` + with `externalTrafficPolicy: Local` on 53/UDP, 53/TCP and 853/TCP. The pods + deliberately do **not** use `hostNetwork`: the server does not set + `SO_REUSEPORT`, so two host-network pods cannot share the node's `:53` and + the second would crash-loop on bind. If your provider cannot health-check + UDP, use DNS-level failover instead of a TCP health check. +* **`NET_BIND_SERVICE`.** The container drops all capabilities and adds only + `NET_BIND_SERVICE`, which exists solely for the low ports (53/443/853). The + pod runs as uid 65532, non-root, with a read-only root filesystem. Drop the + capability and the Service if you move the listeners above 1024. +* **Ingress annotations for SSE.** The live log page needs + `nginx.ingress.kubernetes.io/proxy-buffering: "off"` plus + `proxy-read-timeout` / `proxy-send-timeout: "3600"`. With buffering on, nginx + holds events until the buffer fills and the page looks frozen; with the + default 60 s read timeout the stream is cut every minute and the console + reconnects in a loop. `backend-protocol: HTTP` matches the h2c DoH listener. + The handler also sends `X-Accel-Buffering: no`. +* **Per-pod PVC sizing.** The StatefulSet uses a 4 Gi `ReadWriteOnce` + template. `cache.size` is an *entry count*, not bytes: a packed answer is + roughly 300–600 bytes on the wire and nearer 1 kB resident, so 500k entries + lands around 500 MB, and `qlog.max_entries` is the other consumer. The PVC + cannot be shared between replicas, and being a StatefulSet also means an HPA + cannot drive the replica count. +* Probes all hit `/api/healthz` on the admin port, which is unauthenticated on + purpose. `terminationGracePeriodSeconds: 30` gives the 15 s drain room. The + `reloader.stakater.com/auto` annotation is for reloader-class controllers; + without one, `kubectl rollout restart` is the manual equivalent. +* The TLS Secret `fancy-dns-tls` ships with empty `tls.crt` / `tls.key`; fill + it (cert-manager in production) or the DoT/DoH listeners fail to build their + `tls.Config`. + +## Security notes + +* **`api.trusted_proxies` and `X-Forwarded-For`.** The client IP is the socket + peer unless that peer is inside a `trusted_proxies` CIDR; only then is the + left-most `X-Forwarded-For` entry (or `X-Real-IP`) believed. Put the ingress + controller's pod CIDR there and nothing else. If you trust a range clients + can reach directly — or leave the list empty behind a proxy that rewrites + nothing — a client can send an arbitrary `X-Forwarded-For` and choose its own + identity: it lands in whichever `acl.groups` CIDR it names, escapes a `deny` + group, and gets its own rate-limit bucket, so per-group rate limits and + concurrency caps stop applying to it. +* **`metrics.protect`.** Default `false`, so `/api/metrics` is open to anyone + who can reach the admin port and publishes client addresses, top domains and + upstream names. Set it to `true` (as `deploy/config.example.yaml` does) and + scrape with `X-API-Key`. The settings validator warns when it is off. +* **`api.auth.mode`.** `none` means no credential is checked at all, and the + API can rewrite your config, your ACL and your upstreams. `Validate` refuses + `apikey` with an empty `api_keys` *and* `trust_loopback: false`, because that + combination is `none` in everything but name. `trust_loopback: true` (the + default) is an implicit bypass for anything on the same host — including other + containers sharing the pod's network namespace — so set it to `false` in + Kubernetes. +* **`api.cors_origins: ["*"]`** echoes any origin back with + `Access-Control-Allow-Credentials: true`. Only do that for a console you are + willing to expose to arbitrary web pages. +* **TLS material.** `dns.dot.*`, `dns.doh.*` and `api.cert_file` / `key_file` + are read from disk at start and on reload; the API never writes them. The + config file is written with mode `0600`, and a config written through the API + is broadcast to peers verbatim — so keep API keys and Valkey passwords in a + Secret (via `${VAR}`) rather than in a committed file. A key in a ConfigMap is + readable by anyone with `get configmaps`. +* **Plain-HTTP DoT/DoH.** Enabling `doh` without `cert_file` / `key_file` serves + the endpoint in cleartext and the server only logs a warning. +* **Rate limiting is per client IP, fixed window** (`acl.*.rate_limit`, + `api.rate_limit.per_minute`), so it is a runaway-client guard, not DDoS + defence, and many clients behind one NAT share a bucket. +* **Upstream TLS.** `insecure_skip_verify` on an upstream disables certificate + validation for every query sent to it; the validator warns about it. + `insecure_skip_tls_verify` on a list source disables validation for the + download *and* stops the copy from being persisted. + +## Limitations + +* **No DNSSEC validation.** There is no trust anchor, no validator, no + `dnssec` package. The AD bit, the CD bit, the DO bit and the extended RCODE + are copied from the upstream answer to the client (`engine.fromUpstream`) so a + validating stub sees what the upstream claimed, but fancy-dns neither checks + nor vouches for any of it. `cache.Key` keeps DO in the key because a DO query + legitimately gets a different answer; CD is deliberately not part of the key. +* **No zone transfer.** AXFR/IXFR are not implemented: the master-file parser + knows the record types, but nothing serves or accepts a transfer, and + `zonefile.SerialNext` is unused. +* **No dynamic update.** An UPDATE message passes the engine's shape check + (`validate` tolerates `OpcodeUpdate`) and is then handled as an ordinary + query. There is no RFC 2136 handling, no journal and no NOTIFY. A local zone + only changes when the file changes and the config is reloaded (SIGHUP, + `POST /api/settings/reload`, or a restart). +* **No HTTP/3** for DoH despite `dns.doh.transport.mode` / `alt_svc`; the + listener is h2 with an h2c fallback. +* **No certificate automation**: certificates are files on disk, one per + listener; there is no ACME client and no wildcard handling. +* **Local zones are not a full authoritative server**: no DNSSEC, no transfer, + and a delegated name is resolved upstream rather than answered with a + referral. +* **Unknown config keys are silently ignored** (no `yaml` `KnownFields`), so a + typo such as `block:` instead of `blocklist:` yields a plausible server + instead of an error. Likewise, a list without `enabled: true` is silently + skipped. + +## Development + +```sh +make # help screen +make build # console + binary with the console embedded -> dist/fancy-dns +make go-build # binary without console assets (fast; used by the test loop) +make web # npm ci (if needed) + npm run build -> internal/web/dist +make web-dev # Vite dev server, proxying /api to 127.0.0.1:8080 +make web-check # npm ci + tsc -b --noEmit + npm run build +make check # what CI runs: fmt-check, vet, test +make test # go test -race -count=1 ./... +make test-short # go test -short -count=1 ./... (no race detector) +make cover # coverage.html + a summary line +make bench # cache and blocklist benchmarks +make fmt / make fmt-check / make vet / make lint +make run # dist/fancy-dns-noassets -config deploy/config.example.yaml -log-level debug +make check-config CONFIG=path/to/config.yaml # -check only +make image / make push / make clean / make clean-web +``` + +**The Go build embeds `internal/web/dist`.** `internal/web/web.go` has +`//go:embed all:dist`, and the Vite config writes there. On a fresh clone the +directory holds only a `.gitkeep`, so `npm ci && npm run build` (i.e. `make +web`) must run before a `go build` that should serve the console; otherwise the +server starts, logs "web console assets missing from this binary", and answers +`/` with a page telling you to run `make web`. CI asserts the bundle exists +after `npm run build`. Note that `vite build` empties `outDir` and deletes +`.gitkeep` — restore it afterwards or the directory disappears from git. + +`make test` is what CI runs for Go; the race detector is not optional there +because every hot path uses atomic holders and swap hooks. +`.github/workflows/ci.yml` has four jobs: `go` (gofmt, vet, `go test -race`, a +`-short` run with coverage, and a build without the console), `web` (npm ci, +`tsc -b --noEmit`, build, assert `internal/web/dist/index.html`), `binary` +(build the console, build the binary with it embedded, then a smoke test that +rewrites `deploy/config.example.yaml` to unprivileged ports, asserts +`/api/healthz` answers, that `/` is HTML, that `/api/whoami` is 401 without a +key and 200 with one), and `image` (build and push to ghcr.io on `main`). + +## Known inconsistencies + +Found while writing this file; each is a to-do, not something fixed here. +`README.md` is the only file this change touches. + +1. **`internal/config/config.go:117` — `dns.serve_stale` is dead.** Parsed, + defaulted and shown in `/api/settings`, but nothing reads it; the serve-stale + window comes from `cache.serve_stale` (`internal/app/app.go:startCache`). +2. **`internal/config/config.go:121` — `dns.recursion_desired` is dead.** The + engine always sets `RecursionDesired = true` on outbound queries + (`internal/engine/engine.go:prepareUpstream`). +3. **`internal/config/config.go:94` — `server.max_concurrent_per_client` is + dead.** Per-client caps come from `acl.groups[].max_concurrent`, which its + own comment already points at. +4. **`internal/config/config.go:80-88` — `server.timeouts.query` is dead**; + only defaulted. In the same struct, `server.timeouts.udp` / `.tcp` are not + enforced under those names: the UDP loop uses a hard-coded 500 ms read tick + and 2 s write deadline (`internal/server/server.go:serveUDP`, + `servePacket`), and the TCP/DoT idle timeout is `timeouts.tls`. +5. **`internal/config/config.go:197` — `upstream.servers[].health_name` is + dead.** `Pool.HealthCheck` always probes the dialled address for `. NS` + (`internal/resolver/pool.go`); the field is only set by a test fixture. +6. **`internal/config/config.go:229` — `cache.prewarm` is dead.** + `App.prewarm` exists (`internal/app/app.go:220`) but nothing calls it, while + `deploy/config.example.yaml:97` ships `prewarm: []`. +7. **`internal/config/config.go:358` — `qlog.log_client_names` is dead.** +8. **`internal/config/config.go:373` — `cluster.peer_sync` is dead**, and + **`cluster.advertise`** (`:371`, "the address other instances dial for the + admin API") is only echoed in heartbeats: no code dials a peer. +9. **`internal/config/config.go:411` — `api.allow_blocklist_write` is dead.** + Only `allow_config_write` is enforced (`internal/api/handlers.go: + handleSettingsPut`). +10. **`internal/config/config.go:418` — `metrics.path` is dead.** The route is + built from `api.base_path` (`internal/api/api.go:routes`: + `GET metrics`), so `metrics.path: /metrics` in + `deploy/config.example.yaml:196` and in `deploy/k8s/00-config.yaml` has no + effect; the endpoint is `/api/metrics`. +11. **`internal/config/config.go` — `dns.doh.transport.alt_svc` (`:157`) and + `.mode` (`:156`), `dns.dot.upstream` (`:129`), `dns.edns0.cookie_support` + (`:166`), `dns.edns0.client_subnet` (`:162`), `dns.edns0.ipv4_bits` / + `ipv6_bits` (`:163-164`) are all dead.** `serveDoH` always speaks h2 with an + h2c fallback (no HTTP/3, no Alt-Svc), there is no encrypted-upstream DoT + switch (`upstream.servers[].proto: tls` is the way to get that), and ECS is + forwarded verbatim whenever `dns.edns0.preserve` is on. +12. **`internal/config/config.go:266` — `blocklist.parallelism` is dead**; + `RefreshAll` compiles single-threaded. +13. **`internal/config/config.go` (`ListSource.Timeout`) and + `server.timeouts.list_fetch`** — the fetch timeout is never applied: + `blocklist.NewManager` hardcodes a 120 s `http.Client` and + `internal/blocklist/manager.go:fetch` never consults `l.Timeout`. +14. **`internal/config/config.go` (`UpstreamBootstrap.Refresh`)** — bootstrap + names are resolved once per pool rebuild; `refresh` never triggers a + re-resolution. +15. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is + never applied; `internal/cluster/cluster.go:dial` builds a plain + `redis.Client` with no `TLSConfig`. +16. **`internal/zonefile/zonefile.go:525-526` — the `OriginFromPath` doc + comment is wrong.** It claims `"rpz/ads.zone"` and `"ads.zone"` both become + `"ads.zone."`; the code truncates at the first dot, so both become `"ads."`. + The behaviour is what `localzone.Load` and `blocklist.parseRPZFiles` rely + on — fix the comment. +17. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment + advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not + supported; use blocklist.local_zones or a list source instead`"), and + `Options.MaxIncludeDepth` is never set by any caller. Either implement it or + correct the comment and drop the field. +18. **`Makefile:113` — `make run` claims it runs + `deploy/config.example.yaml` "on unprivileged ports", but that file binds + `0.0.0.0:53` and `[::]:53`** (`deploy/config.example.yaml:23-24`). + Unprivileged use needs the same `sed` rewrite the CI smoke test does + (`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you + can bind. +19. **`deploy/k8s/10-deployment.yaml` — the comment tells the reader to manage + zones "through the admin API (`/api/zones`)", which does not exist.** There + is no zone endpoint in `internal/api/api.go:routes`; local zones are edited + on disk. +20. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave + the LB health check on the HTTP admin port, but the `fancy-dns-dns` + LoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP.** The admin + port is on the separate `fancy-dns-web` ClusterIP Service, which the LB + cannot health-check. Pick a provider with UDP health checks or use DNS-level + failover. +21. **`deploy/k8s/00-config.yaml` enables `dot` and `doh` against + `/etc/fancy-dns/tls/tls.crt` while the Secret it mounts ships empty + `tls.crt` / `tls.key`.** Applying the kustomization unchanged leaves both + listeners unable to build a `tls.Config`; a comment says so, but an example + that cannot come up as-is is still a footgun. +22. **`api.rate_limit.per_minute` defaults to `60` in `config.Default()` but + `deploy/config.example.yaml:189` says `600`**, and + `deploy/config.example.yaml` sets `qlog.log_cached: false` where the default + is `true`. Harmless, but the example is not a dump of the defaults and + readers will assume it is. +23. **`api.auth.mode: apikey` with an emptied `api_keys` list and + `trust_loopback: true` is indistinguishable from `none`.** `Validate` only + rejects the combination with `trust_loopback: false`; the unsafe case is + reported solely as a warning from `POST /api/settings/validate`. +24. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys + are ignored silently (also recorded as an open decision in `STATUS.md`).