# 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`).