The console's TypeScript types are hand-written and apiFetch ends in
`JSON.parse(text) as T`, so nothing in the toolchain compared them with the
Go structs. A field that lost its json tag, or a response a struct never had,
showed up as an empty table in a browser and nowhere else: the Go suite was
green and the console was wrong. internal/app/contract_test.go now asserts the
exact JSON key set of every admin endpoint, driven through the real router
with the real hook implementations, plus a test that drives the two write
endpoints with the exact payload web/src builds.
The drift it found, all four fixed here:
blocklist.Source and SourceMeta had no json tags at all, so GET /api/blocklists
sent Go field names ("URL"/"Format") where the console expects snake_case: the
Sources table rendered blank rows and its per-row toggle posted
`name: undefined`. Tagged to match ParseStats.
acl.GroupSummary was flat where the console reads a nested `match` object — the
same shape config.Group has — so every group card read "matches nothing". The
four match fields are nested now, under a new acl.Match.
PUT /api/clients answered 400 `json: unknown field "ptr_suffixes"` to the
console's payload, because config.Group and config.Match carried yaml tags only
and the decoder runs with DisallowUnknownFields. Both structs have json tags
now; the same applies to config.UpstreamServer and UpstreamBootstrap.
PUT /api/upstreams answered 400 `json: unknown field "health"`, because the
console round-trips the whole server object it read, runtime health fields
included. They are accepted and ignored, because the server owns them, and they
are `yaml:"-"` so settings.yaml does not gain runtime state.
Also fixed, each with a test:
A duration on the wire was a nanosecond count. config.Duration had a
MarshalYAML but no MarshalJSON, so bootstraps[].refresh arrived as 30000000000
while the console typed the field as a string, and healthcheck_interval was a
float of seconds in one place and a string in another. Durations on the wire
are duration strings everywhere now — the same spelling config.yaml uses — and a
bare JSON number is still accepted and means seconds, not nanoseconds.
TestDurationJSONForm used to pin the old behaviour and now pins the new one,
saying in its comment that the old behaviour was the bug.
A log row's key set depended on the row. api.LogEntry was omitempty on
client_group, response, upstream and list, so a query with no ACL group
arrived without the key and the console rendered "undefined" where the column
should be empty. The contract test can only pin one shape if the server emits
one, so nothing on that struct is omitempty any more.
A dead peer stayed in the cluster peer list forever. Cluster.seen was only ever
added to and deleted from on an explicit `bye`, so a node that crashed — or
whose Valkey connection died — was reported as a live peer for the life of the
process, and its stale blocklist digest was flagged as divergence forever.
Peers() now reaps peers unheard for peer_ttl*4 and stamps each state with the
receiver's own clock, so clock skew between two machines can neither reap a
healthy peer nor keep a dead one. Discover() ages out the same way on the
publisher's own last_seen stamp, because the keyed Valkey record outlives a
crash: a live node keeps refreshing the key's TTL, so a dead node's field stayed
readable for the life of the fleet.
peer_ttl: 0 would have crashed the process. time.NewTicker(PeerTTL/2) panics on
a non-positive period and `EXPIRE key 0` deletes the heartbeat key, so a config
that left peer_ttl unset would have taken the process down at Start, or made
every instance believe it is alone in the fleet. Both go through peerTTL() and
peerDeadline(), which fall back to the default.
Two cluster tests raced on the wire, not on the product, and one was
environment-dependent rather than wrong. A node that joined after another's
heartbeat was in flight hears about it on the next tick, so "b does not see a"
read immediately after "a sees b" is a coin flip; the divergence test waited for
a digest on the wrong node's view of the fleet; and 127.0.0.1:1 is only
unbindable outside a container that sets
net.ipv4.ip_unprivileged_port_start=0, where it binds and the partial-bind test
fails with both transports up. That test now occupies a port with a live
listener in the test process, which is a deterministic EADDRINUSE on every
machine and every uid.
internal/server/counters_test.go covers the counter-key layer that no other
test touched: the "<transport>.<field>" keyspace, that the aggregate folds only
the known fields, and that the snapshot is safe while counters move.
Two pinned behaviours changed on purpose, each with a test that says so:
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 —
and an rpz-passthru. record did not allowlist its own domain either. RPZ
semantics say the record applies to the domain and everything under it, so a
bare owner now takes the apex form **.name; 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, which is
why it wanted a decision. TestExampleRPZZoneCoversTheApexItNames pins both
halves and the example zone now says which half it relies on.
metrics.OutcomeLocal had no producer. The engine reported a local-zone answer as
qlog.ActionAllow, which outcomeFor mapped to OutcomeUpstream, so every locally
answered query was counted as an upstream one and the outcome="local" series was
never published — the dashboard's local column was permanently empty.
Response.Local is now set on the local-zone path and outcomeFor consults it
first. That moves published counters, so the README's metrics section says so
too.
830 lines
52 KiB
Markdown
830 lines
52 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` | `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`.
|
||
|
||
`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.
|
||
|
||
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.
|