matsandClaude Sonnet 5.5 bdcdbaf23a Test harness: pick a port that is free for UDP as well as TCP
The DNS listeners bind TCP and UDP on one port, and the harness probed TCP only.
Under a full parallel run the port could be one another test's ephemeral UDP
client socket held, the listener failed to bind, and the queries saw connection
refused. That made TestConsoleEditsReachTheResolver fail once in a full suite and
pass 12 of 12 on its own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:09:46 +02:00
2026-09-28 10:37:15 +00:00
2026-09-27 21:13:35 +02:00
2026-09-27 17:29:08 +02:00
2026-09-27 11:34:26 +00:00
2026-09-28 21:34:59 +02:00

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. Listener addresses are configurable; high ports can be used when the process should not need that capability.

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/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. 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; example.com A 192.0.2.10 redirects matching A queries to that address
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
POST /api/dns/test — run one query through the live pipeline ({"name","type"}) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request
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 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 RFC 2136 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 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 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

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: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.
  17. 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.
  18. 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.
  19. 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.
  20. 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.
  21. 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.
  22. 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.
  23. 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.
  24. 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.

Conditional forwarding

To resolve DHCP names through a firewall, add suffix routes under upstream:

upstream:
  # Keep your existing default servers and policy here.
  forward_zones:
    - zone: local
      servers:
        - addr: 10.10.0.1:53
          proto: udp

local matches both local. and every name below it, including fastpi.local.. Do not write *.local. Matching ignores case and chooses the most specific suffix. Forwarding targets are separate from default servers; matched queries never fall back to public DNS, even if all targets fail. UDP retries truncated replies over TCP. Local authoritative zones retain precedence, and ACL and blocklist policies still apply. The firewall must answer these names itself rather than forwarding them back to fancy-dns.

Rules are configured in YAML, not in the console. Rebuild and deploy a binary with forwarding support before using this key: older versions ignore unknown keys. Send SIGHUP after editing YAML; upstream reloads flush the cache. The Kubernetes ConfigMap includes local forwarding to 10.10.0.1. Roll out the new image/config together or reload the updated config in a compatible running pod. Test with dig @<fancy-dns-address> fastpi.local A.

.local is reserved for multicast DNS, so some clients may send these names to mDNS instead of their configured DNS server. For new residential networks, use home.arpa (RFC 8375). Reverse DHCP lookups can use another route such as 11.10.10.in-addr.arpa for the 10.10.11.0/24 subnet.

S
Description
No description provided
Readme
7.5 MiB
0 Stars 1 Watchers 0 Forks
Languages
Go 81.5%
TypeScript 17.4%
CSS 0.5%
Makefile 0.4%
Dockerfile 0.2%