The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
56 KiB
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.
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:
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
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).
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.managed_zones |
path | empty | the only directory the admin console may write zone data into, one <apex>.zone per zone; empty keeps the console's Zones page read-only |
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 |
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 |
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/wunit suffixes or bare seconds. @means the current origin; a name that ends in a dot is absolute and is never origin-relative.$INCLUDEis rejected:internal/zonefile/zonefile.goreturns "$INCLUDE … is not supported; use blocklist.local_zones or a list source instead". Split the data into morelocal_zonesglobs instead.zonefile.SerialNextimplements 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. Legacy names truncate at the first dot, so
zones/lan.zone becomes lan.. The console marks fully-qualified zone names
with zone--, so zone--example.com.zone becomes example.com.. 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); 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.
Two zone pages, two ownership models. Local zones edits the RFC 1035
master files themselves (in Kubernetes, one file per zone in the
fancy-dns-zones ConfigMap; standalone, the files named by
blocklist.local_zones). Zones is the record-level view and the only one
that writes a directory of its own, blocklist.managed_zones. They are
separate on purpose: the first edits what you wrote, the second writes what it
owns. Neither rewrites the other's files.
Edit zones in the console. Open Local zones to create a zone or edit its
RFC 1035 master-file records. In Kubernetes, the editor stores each file in the
fancy-dns-zones ConfigMap; both replicas watch the projected files and reload
them automatically. The API validates every file and its record owners before
saving. The pod's service account can read and patch only this ConfigMap. The
ConfigMap's data is intentionally left out of the GitOps manifest so console
edits persist across reconciliation. For a standalone install, manage zone
files on disk and reload the configuration.
Zones the console writes. blocklist.managed_zones names a directory the
admin console owns: one <apex>.zone per zone, holding the SOA, the apex NS
set and whatever records were added there. It is a zone source like any other —
localzone.Load compiles it alongside local_zones — so a record added from
the console is served on the next query, with no reload and no restart. The
master files in local_zones are never rewritten: they are listed in the
console's Zones page and shown as read-only. Without managed_zones the page
is a browser and nothing else.
Four rules the writer holds to, all of them because the alternative is a zone that silently stops answering:
- A record line is validated by the project's own master-file parser, so an RDATA name written without a trailing dot means the same thing here as in a zone file, and the value the API echoes is the value the file holds.
- What the writer renders is re-parsed and compared with what it meant to write before anything touches the disk. A round-trip that loses a record is a writer bug, caught there rather than by an operator noticing an empty zone.
- A zone needs an SOA (or it cannot answer NXDOMAIN/NODATA) and an NS record at the apex (or it delegates nothing and has no server to name), so a brand new zone is started with one of the two. The SOA's primary follows the apex NS set, and the serial moves on every write.
- A record at or below a delegation is refused: the store answers those names as delegated and never looks at the zone file, so it would be data that looks alive and never answers.
A record is written by rewriting the whole file, so a file under
managed_zones is the console's to own; hand-maintained data belongs in
local_zones. Each file says so in its header.
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/zones |
— | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (error) |
| POST | /api/zones/records |
yes | add a record ({"zone","name","type","ttl","value"}; a name without a trailing dot is relative to the zone) |
| PUT | /api/zones/records |
yes | replace the whole RRset of one name and type |
| DELETE | /api/zones/records?zone=&name=&type=&value= |
yes | remove one record, or the RRset when value is empty |
| POST | /api/zones/delegations |
yes | delegate a name to other servers ({"zone","name","targets":[…],"ttl"}); the delegation's targets become the apex NS set of a zone that has none |
| DELETE | /api/zones/delegations?zone=&name= |
yes | remove a cut |
| DELETE | /api/zones?zone= |
yes | delete a zone the console owns, so its names go back to whatever answered for them before; refused for a zone with records outside the managed directory |
| 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
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.
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 aCrashLoopBackOff, not an open console. The checked-in Secret holds the placeholderREPLACE-ME; applying it as-is overwrites a real key, and nothing rejects a weak key. - A LoadBalancer is required for DNS.
fancy-dns-dnsis aLoadBalancerwithexternalTrafficPolicy: Localon 53/UDP, 53/TCP and 853/TCP. The pods deliberately do not usehostNetwork: the server does not setSO_REUSEPORT, so two host-network pods cannot share the node's:53and 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 onlyNET_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"plusproxy-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: HTTPmatches the h2c DoH listener. The handler also sendsX-Accel-Buffering: no. - Per-pod PVC sizing. The StatefulSet uses a 4 Gi
ReadWriteOncetemplate.cache.sizeis 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, andqlog.max_entriesis 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/healthzon the admin port, which is unauthenticated on purpose.terminationGracePeriodSeconds: 30gives the 15 s drain room. Thereloader.stakater.com/autoannotation is for reloader-class controllers; without one,kubectl rollout restartis the manual equivalent. - The TLS Secret
fancy-dns-tlsships with emptytls.crt/tls.key; fill it (cert-manager in production) or the DoT/DoH listeners fail to build theirtls.Config.
Security notes
api.trusted_proxiesandX-Forwarded-For. The client IP is the socket peer unless that peer is inside atrusted_proxiesCIDR; only then is the left-mostX-Forwarded-Forentry (orX-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 arbitraryX-Forwarded-Forand choose its own identity: it lands in whicheveracl.groupsCIDR it names, escapes adenygroup, and gets its own rate-limit bucket, so per-group rate limits and concurrency caps stop applying to it.metrics.protect. Defaultfalse, so/api/metricsis open to anyone who can reach the admin port and publishes client addresses, top domains and upstream names. Set it totrue(asdeploy/config.example.yamldoes) and scrape withX-API-Key. The settings validator warns when it is off.api.auth.mode.nonemeans no credential is checked at all, and the API can rewrite your config, your ACL and your upstreams.Validaterefusesapikeywith an emptyapi_keysandtrust_loopback: false, because that combination isnonein 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 tofalsein Kubernetes.api.cors_origins: ["*"]echoes any origin back withAccess-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.*andapi.cert_file/key_fileare read from disk at start and on reload; the API never writes them. The config file is written with mode0600, 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 withget configmaps. - Plain-HTTP DoT/DoH. Enabling
dohwithoutcert_file/key_fileserves 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_verifyon an upstream disables certificate validation for every query sent to it; the validator warns about it.insecure_skip_tls_verifyon 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
dnssecpackage. 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.Keykeeps 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.SerialNextis unused. - No RFC 2136 dynamic update. An UPDATE message passes the engine's shape check
(
validatetoleratesOpcodeUpdate) and is then handled as an ordinary query. There is no wire-protocol update handling, journal or NOTIFY. Local zones are editable from the console's Local zones page. Kubernetes pods reload changed zone files automatically; standalone file edits need a config reload or 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
yamlKnownFields), so a typo such asblock:instead ofblocklist:yields a plausible server instead of an error. Likewise, a list withoutenabled: trueis silently skipped.
Development
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.
internal/config/config.go:117—dns.serve_staleis dead. Parsed, defaulted and shown in/api/settings, but nothing reads it; the serve-stale window comes fromcache.serve_stale(internal/app/app.go:startCache).internal/config/config.go:121—dns.recursion_desiredis dead. The engine always setsRecursionDesired = trueon outbound queries (internal/engine/engine.go:prepareUpstream).internal/config/config.go:94—server.max_concurrent_per_clientis dead. Per-client caps come fromacl.groups[].max_concurrent, which its own comment already points at.internal/config/config.go:80-88—server.timeouts.queryis dead; only defaulted. In the same struct,server.timeouts.udp/.tcpare 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 istimeouts.tls.internal/config/config.go:197—upstream.servers[].health_nameis dead.Pool.HealthCheckalways probes the dialled address for. NS(internal/resolver/pool.go); the field is only set by a test fixture.internal/config/config.go:229—cache.prewarmis dead.App.prewarmexists (internal/app/app.go:220) but nothing calls it, whiledeploy/config.example.yaml:97shipsprewarm: [].internal/config/config.go:358—qlog.log_client_namesis dead.internal/config/config.go:373—cluster.peer_syncis dead, andcluster.advertise(:371, "the address other instances dial for the admin API") is only echoed in heartbeats: no code dials a peer.internal/config/config.go:411—api.allow_blocklist_writeis dead. Onlyallow_config_writeis enforced (internal/api/handlers.go: handleSettingsPut).internal/config/config.go:418—metrics.pathis dead. The route is built fromapi.base_path(internal/api/api.go:routes:GET <base>metrics), sometrics.path: /metricsindeploy/config.example.yaml:196and indeploy/k8s/00-config.yamlhas no effect; the endpoint is/api/metrics.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.serveDoHalways speaks h2 with an h2c fallback (no HTTP/3, no Alt-Svc), there is no encrypted-upstream DoT switch (upstream.servers[].proto: tlsis the way to get that), and ECS is forwarded verbatim wheneverdns.edns0.preserveis on.internal/config/config.go:266—blocklist.parallelismis dead;RefreshAllcompiles single-threaded.internal/config/config.go(ListSource.Timeout) andserver.timeouts.list_fetch— the fetch timeout is never applied:blocklist.NewManagerhardcodes a 120 shttp.Clientandinternal/blocklist/manager.go:fetchnever consultsl.Timeout.internal/config/config.go(UpstreamBootstrap.Refresh) — bootstrap names are resolved once per pool rebuild;refreshnever triggers a re-resolution.internal/config/config.go(Valkey.TLS) —cluster.valkey.tlsis never applied;internal/cluster/cluster.go:dialbuilds a plainredis.Clientwith noTLSConfig.internal/zonefile/zonefile.go:1-8and:238-243— the package comment advertises$INCLUDE, the parser rejects it ("$INCLUDE … is not supported; use blocklist.local_zones or a list source instead"), andOptions.MaxIncludeDepthis never set by any caller. Either implement it or correct the comment and drop the field.Makefile:113—make runclaims it runsdeploy/config.example.yaml"on unprivileged ports", but that file binds0.0.0.0:53and[::]:53(deploy/config.example.yaml:23-24). Unprivileged use needs the samesedrewrite the CI smoke test does (.github/workflows/ci.yml, "Smoke test the real binary"), or a port you can bind.deploy/k8s/20-service.yaml— the load-balancer comment says to leave the LB health check on the HTTP admin port, but thefancy-dns-dnsLoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP. The admin port is on the separatefancy-dns-webClusterIP Service, which the LB cannot health-check. Pick a provider with UDP health checks or use DNS-level failover.deploy/k8s/00-config.yamlenablesdotanddohagainst/etc/fancy-dns/tls/tls.crtwhile the Secret it mounts ships emptytls.crt/tls.key. Applying the kustomization unchanged leaves both listeners unable to build atls.Config; a comment says so, but an example that cannot come up as-is is still a footgun.api.rate_limit.per_minutedefaults to60inconfig.Default()butdeploy/config.example.yaml:189says600, anddeploy/config.example.yamlsetsqlog.log_cached: falsewhere the default istrue. Harmless, but the example is not a dump of the defaults and readers will assume it is.api.auth.mode: apikeywith an emptiedapi_keyslist andtrust_loopback: trueis indistinguishable fromnone.Validateonly rejects the combination withtrust_loopback: false; the unsafe case is reported solely as a warning fromPOST /api/settings/validate.config.Loaddoes not useyaml.Decoder.KnownFields, so unknown keys are ignored silently. A settled decision (pinning, not changing, the behaviour);TestLoadIgnoresUnknownKeysininternal/configholds it.- RESOLVED — an RPZ record did not cover the name it names.
rpzRulegave a bare owner name the subdomain form (*.name), soanalytics.example.org.in a policy zone blockedcdn.analytics.example.org.and leftanalytics.example.org.resolving normally. RPZ semantics say the record applies to the domain and everything under it, sorpzRulenow 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 byTestExampleRPZZoneCoversTheApexItNames, including therpz-passthru.half — the same gap meant a passthru record did not allowlist its own domain. internal/api/api.go:288-317—api.cors_origins: ["*"]echoes the request'sOriginand setsAccess-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 toauth.mode: basicand 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 byTestCORSWildcardEchoesTheOriginandTestCORSPreflight; whether a wildcard should be refused outright is a decision.