Files
fancy-dns/deploy/config.example.yaml
T
developer b0e6b3bb92
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Failing after 45s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
Add the memory envelope, pprof, and a per-packet fix on the UDP path
A cgroup OOM kill is the worst outcome available to a resolver: an outage
rather than a cache miss, with no chance to serve stale on the way out. So:

- memory.limit / memory.reserve / memory.cache_max_bytes, a new config section.
  limit becomes a GOMEMLIMIT through debug.SetMemoryLimit, reduced by the
  reserve. The reserve exists because the Go heap does not account for
  goroutine stacks, runtime metadata, or the bbolt mmap backing the cache
  snapshot and the query log — handing the runtime the full cgroup limit is how
  you get killed by a mechanism that believed it had headroom.
- config.Size, so a budget reads as "512MiB" rather than a byte count converted
  by hand. Strict about junk: "512MiBx" is an error.
- The cache byte budget is now actually fed to the LRU. It already enforced
  maxBytes per shard on insert, but had no setter and nothing ever called one,
  so the footprint of cache.size was unbounded: one cached ANY or TXT answer
  can be tens of kilobytes, so 500k entries is anywhere from 150MB to several GB
  depending on what the traffic asked for.

- api.pprof mounts net/http/pprof on the admin listener. The pprof handlers sit
  on the app-level mux, not inside the API's own handler, so they would bypass
  the auth middleware entirely; the option is therefore refused unless every
  admin address is loopback. Profiling means port-forwarding, so this is safe
  by construction rather than by policy, and cmdline cannot leak an argument
  that happens to be a key.

- serveUDP was calling net.ResolveUDPAddr per packet. The peer already is a
  *net.UDPAddr; that call builds a string, re-parses the port and allocates on
  the busiest path in the process.

Also fixes a flaky test: TestParallelPolicyAnswersFromTheFastestUpstream
asserted the slow upstreams were hit, but the fan-out cancels on the first
answer, so whether they were dispatched before cancellation is a real race. It
now asserts the answer came from the fast upstream and that nothing waited.
2026-09-27 13:41:48 +00:00

220 lines
5.6 KiB
YAML

# fancy-dns — example configuration.
#
# Every value below is a default; delete what you do not need. The admin API
# rewrites this file when you save from the console, so keep it in git and
# review the diff.
version: 0
server:
# In-flight upstream resolutions. Excess queries get SERVFAIL rather than
# queueing without bound.
max_concurrent: 1024
timeouts:
udp: 2s
tcp: 4s
tls: 4s
query: 5s
upstream: 2s
list_fetch: 60s
dns:
udp: ["0.0.0.0:53", "[::]:53"]
tcp: ["0.0.0.0:53", "[::]:53"]
# Advertised EDNS0 buffer size for our own answers.
max_udp_size: 1232
serve_stale: 0s
edns0:
# Forward the client's EDNS-Client-Subnet so upstream picks a nearby
# anycast location.
client_subnet: true
ipv4_bits: 24
ipv6_bits: 56
preserve: true
dot:
enabled: false
listen: ["0.0.0.0:853"]
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
min_version: "1.2"
doh:
enabled: false
listen: ["0.0.0.0:443"]
base_path: /dns-query
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
min_version: "1.2"
allow_post: true
transport:
mode: auto
upstream:
# parallel: race them, take the first good answer. random: try N at random.
# failover: walk the list in order.
policy: parallel
max_attempts: 3
healthcheck_interval: 30s
breaker_threshold: 5
breaker_cooldown: 30s
servers:
- addr: 9.9.9.9:53
proto: udp
health_name: dns.quad9.net
- addr: 149.112.112.112:53
proto: udp
health_name: dns.quad9.net
- addr: 1.1.1.1:53
proto: tls
server_name: cloudflare-dns.com
- addr: 9.9.9.9:853
proto: tls
server_name: dns.quad9.net
# The memory envelope. A cgroup kill is an outage; a cache miss is not, so the
# runtime gets a soft ceiling to work towards instead of finding out the hard
# way. Set limit to your container's memory limit, minus the reserve.
memory:
# Soft heap ceiling (GOMEMLIMIT). 0 = unbounded. The Go heap excludes
# goroutine stacks, runtime metadata and the bbolt mmap for the cache
# snapshot and the query log, which is what the reserve is for.
limit: "0"
reserve: "256MiB"
# Byte budget for the in-memory LRU. 0 derives it from the limit (60%).
# cache.size alone does not bound memory: one cached ANY or TXT answer can
# be tens of kilobytes, so 500k entries is anywhere from 150MB to several GB
# depending on what the traffic asked for.
cache_max_bytes: "0"
cache:
enabled: true
size: 200000
shards: 32
min_ttl: 0s
max_ttl: 1h
# Keep expired answers around this long so a dead upstream costs latency,
# not availability.
serve_stale: 30m
prefetch: true
negative_ttl: 30m
persist:
enabled: true
path: data/cache.db
interval: 30s
max_entries: 500000
load_on_start: true
prewarm: []
blocklist:
enabled: true
# nxdomain | zero | redirect | drop | refused
default_action: nxdomain
response_ip: 0.0.0.0
response_ipv6: "::"
response_ttl: 60s
# RPZ-style policy zones and/or local authoritative data, as master files.
local_zones: []
# Inline rules. "*.example.com" covers subdomains, "**.example.com" covers
# the apex too, a bare name matches only itself.
block_entries: []
allow_entries: []
lists:
- name: haas
format: plain
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
path: data/lists/hosts
enabled: true
refresh: 24h
categories: [ads, tracking]
- name: easylist
format: adblock
url: https://easylist.to/easylist/easylist.txt
path: data/lists/easylist.txt
enabled: false
refresh: 24h
categories: [ads]
- name: local-rpz
format: rpz
path: zones/local.rpz.zone
enabled: true
refresh: 0s
acl:
# allow | deny | block_only
default_policy: allow
default_rate_limit: 0
groups:
- name: guests
comment: Blocklists apply, but the kitchen-sink allowlist does not.
match:
cidrs: ["192.168.90.0/24"]
policy: block_only
rate_limit: 50
- name: printers
comment: No recursion at all; they only need DHCP.
match:
cidrs: ["192.168.91.0/24"]
policy: deny
qlog:
enabled: true
path: data/queries.db
max_entries: 200000
flush_interval: 1s
# 1.0 logs every query. Lower it on a busy instance: the per-domain
# counters are what the dashboard actually needs.
sample_rate: 1.0
log_blocked: true
log_cached: false
log_errors: true
live_buffer: 2000
top_domains: 5000
top_ttl: 5m
cluster:
enabled: false
# node_id: dns-1
# advertise: http://10.0.0.10:8080
peer_ttl: 15s
valkey:
addrs: ["valkey:6379"]
prefix: fancydns
pool_size: 10
dial_timeout: 2s
read_timeout: 2s
# Only the lease holder fetches lists, so N instances do not hit a
# mirror N times.
leader_ttl: 30s
api:
enabled: true
listen: ["127.0.0.1:8080"]
# tls_listen: ["0.0.0.0:8443"]
base_path: /api
ui: true
allow_config_write: true
allow_blocklist_write: true
# Only these proxies may set X-Forwarded-For; anything else is ignored, so a
# client cannot spoof its identity past the ACL or the rate limiter.
trusted_proxies: []
cors_origins: []
auth:
# none | apikey | basic
mode: apikey
api_keys: ["CHANGE-ME-long-random-string"]
read_only_api_keys: []
trust_loopback: true
session_ttl: 12h
rate_limit:
per_minute: 600
failures: 10
lockout: 5m
metrics:
enabled: true
path: /metrics
protect: true
log:
level: info
format: text