844 lines
53 KiB
Markdown
844 lines
53 KiB
Markdown
# 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 <this repository> 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 <http://127.0.0.1:8080/> and the metrics at
|
||
<http://127.0.0.1:8080/api/metrics>.
|
||
|
||
### 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/<version>` 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` \| `oidc` | `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.oidc_issuer` / `oidc_audience` / `oidc_client_id` | strings | empty | OIDC issuer, accepted access-token audience, and public browser client id; required in `oidc` mode |
|
||
| `api.auth.oidc_write_roles` | list | `[admin]` | OIDC roles allowed to make changes; other authenticated users are read-only |
|
||
| `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`.
|
||
|
||
`fancydns_query_duration_seconds` carries an `outcome` label with the values
|
||
`cache`, `blocked`, `local`, `upstream`, `error`, `refused`, `dropped`. `local`
|
||
is the series for queries answered from `blocklist.local_zones`; it used to have
|
||
no producer, so it was never published and every locally answered query was
|
||
counted as `upstream`. A dashboard that showed an "upstream" column now sees
|
||
local traffic counted correctly — see STATUS.md for the change note.
|
||
|
||
### 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: `<prefix>:nodes`, `<prefix>:leader`, `<prefix>:events:<kind>` |
|
||
| `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 <ttl>` sets the default TTL, `$ORIGIN <name>` 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).
|
||
|
||
The practical consequence: a *relative* name is qualified with the file's base
|
||
name, not with the zone you had in mind. In `local.example.zone` (origin
|
||
`local.`) the record `ns1 A 192.168.1.2` becomes `ns1.local.`, and in a policy
|
||
zone `*.doubleclick.net` becomes `*.doubleclick.net.local.` — a rule that blocks
|
||
nothing you asked about. Both shipped examples therefore spell every name out in
|
||
full, which is the only spelling that does not depend on what the file is
|
||
called: `TestExampleLocalZoneIsOriginIndependent` (`internal/zonefile`) and
|
||
`TestExampleRPZZoneBlocksWhatItSays` (`internal/blocklist`) fail if they are
|
||
"tidied up" back to relative names.
|
||
|
||
**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 but **not** the name itself (see [Known inconsistencies](#known-inconsistencies)); the rdata carries the action | by the question: exact `name\|TYPE`, then `*.<name>`, 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; a bare owner name covers the domain and everything under it (`**.name`, RPZ semantics), an owner written `*.name` covers only its subdomains |
|
||
| `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.
|
||
|
||
`oidc` mode signs the console in through a public PKCE client. Configure
|
||
`oidc_issuer` (for go-auth, `https://users.k6n.net`), `oidc_client_id`, and
|
||
`oidc_audience` (the resource audience included in the client's access tokens).
|
||
Register `https://<fancy-dns-host>/auth/oidc/callback` as a redirect URI, and
|
||
include the configured audience in the client's `audience` list. The API
|
||
validates RS256 signatures and issuer, audience and expiry using OIDC discovery
|
||
and JWKS. Users carrying a role listed in `oidc_write_roles` (default `admin`)
|
||
can make changes; other valid users have read-only access. Discovery metadata
|
||
is fetched lazily on the first bearer-token request. Set `trust_loopback: false`
|
||
on networked deployments if local requests should also require an OIDC token;
|
||
the loopback bypass is independent of the selected auth mode.
|
||
|
||
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:<version> .
|
||
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:<version> -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 <base>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. A settled decision (pinning, not changing, the
|
||
behaviour); `TestLoadIgnoresUnknownKeys` in `internal/config` holds it.
|
||
25. **RESOLVED — an RPZ record did not cover the name it names.**
|
||
`rpzRule` gave a bare owner name the subdomain form (`*.name`), so
|
||
`analytics.example.org.` in a policy zone blocked
|
||
`cdn.analytics.example.org.` and left `analytics.example.org.` resolving
|
||
normally. RPZ semantics say the record applies to the domain and everything
|
||
under it, so `rpzRule` now uses the apex form (`**.name`) for a bare owner.
|
||
An owner written with an explicit `*.` is still strict, so a zone that wants
|
||
subdomain-only behaviour can still ask for it. This changes which queries
|
||
get blocked for an existing policy zone: re-read your zones if they relied
|
||
on the old (buggy) strict behaviour. Pinned by
|
||
`TestExampleRPZZoneCoversTheApexItNames`, including the `rpz-passthru.`
|
||
half — the same gap meant a passthru record did not allowlist its own
|
||
domain.
|
||
26. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the
|
||
request's `Origin` *and* sets `Access-Control-Allow-Credentials: true`.**
|
||
That is deliberate (a literal `*` is ignored by browsers when credentials
|
||
are involved, so echoing is the only way a wildcard can work with a session
|
||
token), but it does mean a wildcard CORS origin lets any website drive the
|
||
admin API with whatever the browser already has cached for that host —
|
||
relevant to `auth.mode: basic` and to a loopback-trusted console. The
|
||
preflight also answers 204 for *any* origin and any path, and credentials
|
||
are advertised for exact origins too. Pinned by
|
||
`TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight`; whether a
|
||
wildcard should be refused outright is a decision.
|