Files
fancy-dns/internal/config/config.go
T
2026-09-27 11:20:34 +00:00

1099 lines
34 KiB
Go

// Package config holds the declarative configuration for fancy-dns, its
// validation, and the atomic hot-reloadable store the whole server reads from.
//
// Configuration is intentionally file-based (YAML for the service itself,
// DNS master files for zone data) so that a deployment can be reviewed,
// diffed and rolled back with git. Mutations made through the admin API are
// written back to the same file and then broadcast to the cluster.
package config
import (
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Duration is a time.Duration that unmarshals from a YAML/JSON string such as
// "30s" or "1h30m". A bare integer is interpreted as seconds.
type Duration time.Duration
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err == nil {
s = strings.TrimSpace(s)
if s == "" {
*d = 0
return nil
}
// Bare numbers are seconds, matching the convention used by the
// surrounding YAML (and by CoreDNS-ish configs people copy around).
if n, err := time.ParseDuration(s); err == nil {
*d = Duration(n)
return nil
}
return fmt.Errorf("invalid duration %q", s)
}
var f float64
if err := value.Decode(&f); err != nil {
return fmt.Errorf("invalid duration: %s", value.Value)
}
*d = Duration(time.Duration(f * float64(time.Second)))
return nil
}
func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil }
func (d Duration) D() time.Duration { return time.Duration(d) }
// Config is the root document. Every section has sane defaults applied by
// Default() so that a minimal config file is still a working server.
type Config struct {
// Version is bumped by the admin API when it rewrites the file; it is
// echoed in /api/config and used to detect divergence in the cluster.
Version int64 `yaml:"version,omitempty"`
Server Server `yaml:"server"`
DNS DNS `yaml:"dns"`
Upstream Upstream `yaml:"upstream"`
Cache Cache `yaml:"cache"`
Block Block `yaml:"blocklist"`
ACL ACL `yaml:"acl"`
QueryLog QueryLog `yaml:"qlog"`
Cluster Cluster `yaml:"cluster"`
API API `yaml:"api"`
Metrics Metrics `yaml:"metrics"`
Log Log `yaml:"log"`
}
// Server groups the process-level knobs.
type Server struct {
// MaxConcurrent caps in-flight upstream resolutions; excess queries are
// answered with SERVFAIL rather than queueing without bound.
MaxConcurrent int `yaml:"max_concurrent"`
// MaxConcurrentPerClient mirrors MaxConcurrent but per client IP. 0 = off.
MaxConcurrentPerClient int `yaml:"max_concurrent_per_client"`
Timeouts Timeout `yaml:"timeouts"`
// WorkingDir is only informational (shown in the UI).
WorkingDir string `yaml:"working_dir,omitempty"`
}
type Timeout struct {
UDP Duration `yaml:"udp"`
TCP Duration `yaml:"tcp"`
TLS Duration `yaml:"tls"`
Query Duration `yaml:"query"`
Upstre Duration `yaml:"upstream"`
List Duration `yaml:"list_fetch"`
}
// DNS describes every listener the server exposes.
type DNS struct {
UDP []string `yaml:"udp"`
TCP []string `yaml:"tcp"`
DoT DoT `yaml:"dot"`
DoH DoH `yaml:"doh"`
EDNS EDNS `yaml:"edns0"`
// ServeStale answers from cache after an upstream failure.
ServeStale Duration `yaml:"serve_stale"`
// MaxUDPSize is what we advertise as our own EDNS0 buffer size.
MaxUDPSize uint16 `yaml:"max_udp_size"`
// Truncate policy: "question" sets TC and returns the header+question.
Recursion bool `yaml:"recursion_desired"`
// HideIdentity stops leaking our version string in CHAOS/bind.
HideIdentity bool `yaml:"hide_identity"`
}
type DoT struct {
Enabled bool `yaml:"enabled"`
Listen []string `yaml:"listen"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
// ClientCA enables mTLS when set; clients must then present a cert.
ClientCA string `yaml:"client_ca"`
// MinVersion is "1.2" or "1.3".
MinVersion string `yaml:"min_version"`
// Upstream makes our own outgoing queries encrypted (RFC 7858, 8.4.1).
Upstream bool `yaml:"upstream"`
}
type DoH struct {
Enabled bool `yaml:"enabled"`
Listen []string `yaml:"listen"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
BasePath string `yaml:"base_path"`
// EchConfig is an optional ECHConfigList to serve over HTTPS (RFC 9460).
MinVersion string `yaml:"min_version"`
// AllowPost enables the RFC 8484 POST profile in addition to GET.
AllowPost bool `yaml:"allow_post"`
Transport Transport `yaml:"transport"`
}
// Transport selects between HTTP/2 (h2) and HTTP/3 (h3) for DoH.
type Transport struct {
// "auto" upgrades to HTTP/3 when the listener is UDP, else h2.
Mode string `yaml:"mode"`
// AltSvc lets the listener advertise h3 via Alt-Svc.
AltSvc string `yaml:"alt_svc"`
}
type EDNS struct {
// ClientSubnet enables RFC 7871 EDNS-Client-Subnet passthrough.
ClientSubnet bool `yaml:"client_subnet"`
IPv4Bits uint8 `yaml:"ipv4_bits"`
IPv6Bits uint8 `yaml:"ipv6_bits"`
// MaxUDPSize overrides the advertised EDNS0 buffer for our own answers.
CookieSupport bool `yaml:"cookie_support"`
// PreserveEDNS0 copies the incoming OPT options into upstream queries.
PreserveEDNS0 bool `yaml:"preserve"`
}
type Upstream struct {
// Servers is the upstream pool.
Servers []UpstreamServer `yaml:"servers"`
// Policy is "parallel" (query all, first answer wins), "random" (pick N)
// or "failover" (walk the list in order).
Policy string `yaml:"policy"`
// MaxAttempts bounds how many upstreams are tried per query.
MaxAttempts int `yaml:"max_attempts"`
// Healthcheck interval; 0 disables active health probing.
HealthCheck Duration `yaml:"healthcheck_interval"`
// CircuitBreaker trips an upstream after this many consecutive failures.
BreakerThreshold int `yaml:"breaker_threshold"`
BreakerCooldown Duration `yaml:"breaker_cooldown"`
// Bootstraps resolves upstream hostnames at startup and on change.
Bootstraps []UpstreamBootstrap `yaml:"bootstraps"`
}
type UpstreamServer struct {
// Addr is host:port; host may be an IP or a name resolved via Bootstraps.
Addr string `yaml:"addr"`
// Proto is "udp", "tcp" or "tls" (DoT, 8.4.1).
Proto string `yaml:"proto"`
// ServerName / InsecureSkipVerify are only used with proto: tls.
ServerName string `yaml:"server_name"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
// HealthPath is a hostname used for health probes instead of the Addr host.
HealthName string `yaml:"health_name"`
// Weight biases random selection (default 1).
Weight int `yaml:"weight"`
// MaxFails before the breaker opens; 0 uses the global threshold.
MaxFails int `yaml:"max_fails"`
Comment string `yaml:"comment,omitempty"`
}
type UpstreamBootstrap struct {
Name string `yaml:"name"`
Addresses []string `yaml:"addresses"`
// Refresh re-resolves periodically (for dyn names).
Refresh Duration `yaml:"refresh"`
}
type Cache struct {
Enabled bool `yaml:"enabled"`
// Size is the in-memory entry budget for the LRU.
Size int `yaml:"size"`
// Shards must be a power of two; 0 picks 32.
Shards int `yaml:"shards"`
// MinTTL/MaxTTL clamp the TTLs taken from upstream answers.
MinTTL Duration `yaml:"min_ttl"`
MaxTTL Duration `yaml:"max_ttl"`
// ServeStale keeps expired entries around for this long so a dead
// upstream does not turn into a latency cliff.
ServeStale Duration `yaml:"serve_stale"`
// Prefetch keeps ServeStale windows warm.
Prefetch bool `yaml:"prefetch"`
// Persist writes a snapshot to bbolt so restarts stay warm.
Persist CachePersist `yaml:"persist"`
// Prewarm is a list of "domain/type" (e.g. "example.com/A") to load at boot.
Prewarm []string `yaml:"prewarm"`
// Negative caches NXDOMAIN/NODATA, bounded by the SOA minimum.
NegativeTTL Duration `yaml:"negative_ttl"`
}
type CachePersist struct {
Enabled bool `yaml:"enabled"`
// Path is the bbolt file; defaults under the data directory.
Path string `yaml:"path"`
// Interval is the snapshot write cadence.
Interval Duration `yaml:"interval"`
// MaxEntries caps the persisted snapshot (0 = no cap).
MaxEntries int `yaml:"max_entries"`
// LoadOnStart reads a snapshot from disk at boot.
LoadOnStart bool `yaml:"load_on_start"`
}
type Block struct {
Enabled bool `yaml:"enabled"`
// DefaultAction is "nxdomain", "zero", "redirect" or "drop".
DefaultAction string `yaml:"default_action"`
// ResponseIP/ResponseIPv6 are used by the "zero" action.
ResponseIP string `yaml:"response_ip"`
ResponseIPv6 string `yaml:"response_ipv6"`
ResponseTTL Duration `yaml:"response_ttl"`
// ResponseName/CNAME are used by the "redirect" action.
RedirectName string `yaml:"redirect_name"`
// LocalZones are DNS master files (RFC 1035) holding RPZ-style policy
// records and/or locally defined zones.
LocalZones []string `yaml:"local_zones"`
// Lists are remote or on-disk sources of block data.
Lists []ListSource `yaml:"lists"`
// AllowEntries and BlockEntries are inline rules (a list of DNS names or
// master-file lines), useful for small static policies.
AllowEntries []string `yaml:"allow_entries"`
BlockEntries []string `yaml:"block_entries"`
// Parallelism for list parsing/compilation.
Parallelism int `yaml:"parallelism"`
}
type ListSource struct {
Name string `yaml:"name"`
// Format is auto|hosts|adblock|plain|rpz.
Format string `yaml:"format"`
// URL and Path: at least one is required. URL wins when both are set; the
// downloaded copy is written to Path so restarts work without network.
URL string `yaml:"url"`
Path string `yaml:"path"`
Mirror []string `yaml:"mirror"`
Enabled bool `yaml:"enabled"`
// Refresh interval; 0 disables scheduled refresh (manual only).
Refresh Duration `yaml:"refresh"`
// Timeout for the fetch.
Timeout Duration `yaml:"timeout"`
// MaxBytes guards against a runaway/hostile list.
MaxBytes int64 `yaml:"max_bytes"`
// ETag / LastModified enable conditional refreshes.
ETag string `yaml:"etag"`
LastModified string `yaml:"last_modified"`
// Category tags feed the UI breakdown.
Categories []string `yaml:"categories"`
// InsecureSkipTLSVerify is only for self-hosted mirrors.
InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"`
// LastGood keeps the previous compiled set if a refresh fails.
Comment string `yaml:"comment,omitempty"`
}
type ACL struct {
// DefaultPolicy is "allow", "deny" or "block_only".
DefaultPolicy string `yaml:"default_policy"`
// DefaultRateLimit is queries/second per client IP; 0 = unlimited.
DefaultRateLimit int `yaml:"default_rate_limit"`
Groups []Group `yaml:"groups"`
}
// Group is a named set of clients sharing a policy. Groups are evaluated
// top-to-bottom; the first match wins.
type Group struct {
Name string `yaml:"name"`
Comment string `yaml:"comment,omitempty"`
Match Match `yaml:"match"`
// Policy is "allow" (full resolution), "block_only" (blocklists still
// apply, everything else resolves) or "deny" (REFUSED).
Policy string `yaml:"policy"`
// AllowDomains / DenyDomains are per-group overrides; the names support
// the same wildcards as blocklists ("*.example.com", "**.example.com").
AllowDomains []string `yaml:"allow_domains"`
DenyDomains []string `yaml:"deny_domains"`
// BlocklistToggle lets a group opt out of blocklist filtering.
SkipBlocklist bool `yaml:"skip_blocklist"`
// CacheToggle lets a group bypass the cache (fresh lookups only).
SkipCache bool `yaml:"skip_cache"`
// RateLimit overrides ACL.DefaultRateLimit for this group.
RateLimit int `yaml:"rate_limit"`
// MaxConcurrent overrides Server.MaxConcurrentPerClient.
MaxConcurrent int `yaml:"max_concurrent"`
// LogQueries forces query logging for this group regardless of sampling.
LogQueries bool `yaml:"log_queries"`
Upstreams []string `yaml:"upstreams"`
}
// Match selects clients by network identity.
type Match struct {
// CIDRs are matched against the client address (v4 or v6).
CIDRs []string `yaml:"cidrs"`
// Networks match DNS clients identified by a resolved PTR of their
// reverse name; the name must end with one of the suffixes.
PTRSuffixes []string `yaml:"ptr_suffixes"`
// Tags are matched against the client tag set derived from PTR labels,
// e.g. "laptop" for "laptop.lan.internal.".
Tags []string `yaml:"tags"`
// Always matches (useful as a catch-all fallback group).
Any bool `yaml:"any"`
}
type QueryLog struct {
Enabled bool `yaml:"enabled"`
// Path is the bbolt file backing the query log ring.
Path string `yaml:"path"`
// MaxEntries bounds the ring; oldest are dropped.
MaxEntries int `yaml:"max_entries"`
// FlushInterval batches writes.
FlushInterval Duration `yaml:"flush_interval"`
// SampleRate is 0..1 for non-blocked, non-error queries.
SampleRate float64 `yaml:"sample_rate"`
// What to record.
LogBlocked bool `yaml:"log_blocked"`
LogCached bool `yaml:"log_cached"`
LogErrors bool `yaml:"log_errors"`
LogClients bool `yaml:"log_client_names"`
// LiveBuffer is the ring of recent entries kept in memory for the UI/SSE.
LiveBuffer int `yaml:"live_buffer"`
// Retention of aggregated per-domain counters.
TopDomains int `yaml:"top_domains"`
TopTTL Duration `yaml:"top_ttl"`
}
type Cluster struct {
Enabled bool `yaml:"enabled"`
// NodeID defaults to hostname; instances use it to elect a refresh leader.
NodeID string `yaml:"node_id"`
// Advertise is the address other instances dial for the admin API.
Advertise string `yaml:"advertise"`
// PeerSync makes this instance push its config file to peers.
PeerSync bool `yaml:"peer_sync"`
PeerTTL Duration `yaml:"peer_ttl"`
Valkey Valkey `yaml:"valkey"`
}
type Valkey struct {
// Addrs is a list of host:port.
Addrs []string `yaml:"addrs"`
// Username/Password for ACL-based auth.
Username string `yaml:"username"`
Password string `yaml:"password"`
DB int `yaml:"db"`
Prefix string `yaml:"prefix"`
TLS bool `yaml:"tls"`
PoolSize int `yaml:"pool_size"`
// DialTimeout/ReadTimeout bound cluster operations.
DialTimeout Duration `yaml:"dial_timeout"`
ReadTimeout Duration `yaml:"read_timeout"`
// LeaderTTL is the lease length for list-refresh leadership.
LeaderTTL Duration `yaml:"leader_ttl"`
}
type API struct {
Enabled bool `yaml:"enabled"`
// Listen is the plain HTTP admin/API listener.
Listen []string `yaml:"listen"`
// TLSListen serves the admin API (and UI) over TLS instead.
TLSListen []string `yaml:"tls_listen"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
BasePath string `yaml:"base_path"`
Auth Auth `yaml:"auth"`
RateLimit AuthRate `yaml:"rate_limit"`
// UI toggles the embedded admin console.
UI bool `yaml:"ui"`
// AllowConfigWrite lets the API rewrite the config file.
AllowConfigWrite bool `yaml:"allow_config_write"`
// AllowBlocklistWrite lets the API write blocklist files to disk.
AllowBlocklistWrite bool `yaml:"allow_blocklist_write"`
// TrustedProxies is the set of proxies allowed to set X-Forwarded-For.
TrustedProxies []string `yaml:"trusted_proxies"`
// CORSOrigins; "*" is allowed but discouraged.
CORSOrigins []string `yaml:"cors_origins"`
}
type Auth struct {
// Mode is "none", "apikey" or "basic".
Mode string `yaml:"mode"`
// APIKeys grant full access.
APIKeys []string `yaml:"api_keys"`
// ReadOnlyAPIKeys may only call GET endpoints.
ReadOnlyAPIKeys []string `yaml:"read_only_api_keys"`
// Basic user; Password may be plaintext (bcrypt-hashed at load time) or a
// bcrypt hash (prefix "$2").
Username string `yaml:"username"`
Password string `yaml:"password"`
SessionTTL Duration `yaml:"session_ttl"`
// TrustLoopback skips auth for requests from 127.0.0.0/8 and ::1.
TrustLoopback bool `yaml:"trust_loopback"`
}
type AuthRate struct {
// Requests per minute per IP for the login endpoint.
PerMinute int `yaml:"per_minute"`
// Failures per minute per IP before a temporary lockout.
Failures int `yaml:"failures"`
Lockout Duration `yaml:"lockout"`
}
type Metrics struct {
Enabled bool `yaml:"enabled"`
Path string `yaml:"path"`
// Protect requires the API token for /metrics.
Protect bool `yaml:"protect"`
}
type Log struct {
// Level is debug|info|warn|error.
Level string `yaml:"level"`
// Format is text|json.
Format string `yaml:"format"`
// Addr overrides the log destination; empty means stderr.
Addr string `yaml:"addr"`
}
// Default returns a fully-populated configuration. Callers are expected to
// unmarshal the user file on top of it, then Validate.
func Default() *Config {
return &Config{
Server: Server{
MaxConcurrent: 1024,
MaxConcurrentPerClient: 0,
Timeouts: Timeout{
UDP: Duration(2 * time.Second),
TCP: Duration(4 * time.Second),
TLS: Duration(4 * time.Second),
Query: Duration(5 * time.Second),
Upstre: Duration(2 * time.Second),
List: Duration(60 * time.Second),
},
},
DNS: DNS{
UDP: []string{"0.0.0.0:53", "[::]:53"},
TCP: []string{"0.0.0.0:53", "[::]:53"},
MaxUDPSize: 1232,
Recursion: true,
DoT: DoT{MinVersion: "1.2", Listen: []string{"0.0.0.0:853"}},
DoH: DoH{BasePath: "/dns-query", MinVersion: "1.2", AllowPost: true, Transport: Transport{Mode: "auto"}, Listen: []string{"0.0.0.0:443"}},
EDNS: EDNS{ClientSubnet: true, IPv4Bits: 24, IPv6Bits: 56, PreserveEDNS0: true},
ServeStale: Duration(0),
},
Upstream: Upstream{
Policy: "parallel",
Servers: []UpstreamServer{
{Addr: "9.9.9.9:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"},
{Addr: "149.112.112.112:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"},
{Addr: "1.1.1.1:53", Proto: "tls", ServerName: "cloudflare-dns.com", Weight: 1, HealthName: "cloudflare.com"},
{Addr: "9.9.9.9:853", Proto: "tls", ServerName: "dns.quad9.net", Weight: 1, HealthName: "dns.quad9.net"},
{Addr: "1.0.0.1:53", Proto: "udp", Weight: 1, HealthName: "one.one.one.one"},
},
MaxAttempts: 3,
HealthCheck: Duration(30 * time.Second),
BreakerThreshold: 5,
BreakerCooldown: Duration(30 * time.Second),
},
Cache: Cache{
Enabled: true,
Size: 200000,
Shards: 32,
MinTTL: Duration(0),
MaxTTL: Duration(time.Hour),
ServeStale: Duration(30 * time.Minute),
Prefetch: true,
NegativeTTL: Duration(30 * time.Minute),
Persist: CachePersist{
Enabled: true,
Interval: Duration(30 * time.Second),
MaxEntries: 500000,
LoadOnStart: true,
},
},
Block: Block{
Enabled: true,
DefaultAction: "nxdomain",
ResponseIP: "0.0.0.0",
ResponseIPv6: "::",
ResponseTTL: Duration(60 * time.Second),
Parallelism: 4,
},
ACL: ACL{
DefaultPolicy: "allow",
},
QueryLog: QueryLog{
Enabled: true,
MaxEntries: 200000,
FlushInterval: Duration(time.Second),
SampleRate: 1,
LogBlocked: true,
LogCached: true,
LogErrors: true,
LiveBuffer: 2000,
TopDomains: 5000,
TopTTL: Duration(5 * time.Minute),
},
Cluster: Cluster{
Enabled: false,
PeerTTL: Duration(15 * time.Second),
Valkey: Valkey{
Prefix: "fancydns",
PoolSize: 10,
DialTimeout: Duration(2 * time.Second),
ReadTimeout: Duration(2 * time.Second),
LeaderTTL: Duration(30 * time.Second),
},
},
API: API{
Enabled: true,
Listen: []string{"127.0.0.1:8080"},
BasePath: "/api",
UI: true,
AllowConfigWrite: true,
AllowBlocklistWrite: true,
Auth: Auth{
Mode: "apikey",
SessionTTL: Duration(12 * time.Hour),
TrustLoopback: true,
},
RateLimit: AuthRate{PerMinute: 60, Failures: 10, Lockout: Duration(5 * time.Minute)},
},
Metrics: Metrics{Enabled: true, Path: "/metrics"},
Log: Log{Level: "info", Format: "text"},
}
}
// Load reads path (if non-empty) on top of Default and validates the result.
func Load(path string) (*Config, error) {
cfg := Default()
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
cfg.applyDefaults()
cfg.WorkingDirOrSet(filepath.Dir(path))
} else {
cfg.WorkingDirOrSet(".")
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// WorkingDirOrSet records the config directory so relative paths in the
// document resolve consistently regardless of the process cwd.
func (c *Config) WorkingDirOrSet(dir string) {
if c.Server.WorkingDir == "" {
c.Server.WorkingDir = dir
}
}
// applyDefaults backfills values that a hand-written file may omit, and
// normalises "off"/"disabled"/"" style booleans.
func (c *Config) applyDefaults() {
d := Default()
if c.Server.MaxConcurrent <= 0 {
c.Server.MaxConcurrent = d.Server.MaxConcurrent
}
if c.Server.Timeouts.UDP <= 0 {
c.Server.Timeouts.UDP = d.Server.Timeouts.UDP
}
if c.Server.Timeouts.TCP <= 0 {
c.Server.Timeouts.TCP = d.Server.Timeouts.TCP
}
if c.Server.Timeouts.TLS <= 0 {
c.Server.Timeouts.TLS = d.Server.Timeouts.TLS
}
if c.Server.Timeouts.Query <= 0 {
c.Server.Timeouts.Query = d.Server.Timeouts.Query
}
if c.Server.Timeouts.Upstre <= 0 {
c.Server.Timeouts.Upstre = d.Server.Timeouts.Upstre
}
if c.Server.Timeouts.List <= 0 {
c.Server.Timeouts.List = d.Server.Timeouts.List
}
if c.DNS.MaxUDPSize < 512 {
c.DNS.MaxUDPSize = d.DNS.MaxUDPSize
}
if c.DNS.DoH.BasePath == "" {
c.DNS.DoH.BasePath = d.DNS.DoH.BasePath
}
if c.DNS.DoT.MinVersion == "" {
c.DNS.DoT.MinVersion = d.DNS.DoT.MinVersion
}
if c.DNS.DoH.MinVersion == "" {
c.DNS.DoH.MinVersion = d.DNS.DoH.MinVersion
}
if c.DNS.DoH.Transport.Mode == "" {
c.DNS.DoH.Transport.Mode = "auto"
}
if c.EDNSBits() {
// nothing; kept for symmetry
}
if c.EDNS().IPv4Bits == 0 {
c.DNS.EDNS.IPv4Bits = d.DNS.EDNS.IPv4Bits
}
if c.DNS.EDNS.IPv6Bits == 0 {
c.DNS.EDNS.IPv6Bits = d.DNS.EDNS.IPv6Bits
}
if c.Upstream.Policy == "" {
c.Upstream.Policy = d.Upstream.Policy
}
if c.Upstream.MaxAttempts <= 0 {
c.Upstream.MaxAttempts = d.Upstream.MaxAttempts
}
if c.Upstream.BreakerThreshold <= 0 {
c.Upstream.BreakerThreshold = d.Upstream.BreakerThreshold
}
if c.Upstream.BreakerCooldown <= 0 {
c.Upstream.BreakerCooldown = d.Upstream.BreakerCooldown
}
if c.Cache.Size <= 0 {
c.Cache.Size = d.Cache.Size
}
if c.Cache.Shards <= 0 {
c.Cache.Shards = d.Cache.Shards
}
c.Cache.Shards = pow2(c.Cache.Shards)
if c.Cache.MaxTTL <= 0 {
c.Cache.MaxTTL = d.Cache.MaxTTL
}
if c.Cache.NegativeTTL <= 0 {
c.Cache.NegativeTTL = d.Cache.NegativeTTL
}
if c.Cache.Persist.Interval <= 0 {
c.Cache.Persist.Interval = d.Cache.Persist.Interval
}
if c.Block.DefaultAction == "" {
c.Block.DefaultAction = d.Block.DefaultAction
}
if c.Block.ResponseTTL <= 0 {
c.Block.ResponseTTL = d.Block.ResponseTTL
}
if c.Block.Parallelism <= 0 {
c.Block.Parallelism = d.Block.Parallelism
}
if c.ACL.DefaultPolicy == "" {
c.ACL.DefaultPolicy = d.ACL.DefaultPolicy
}
if c.QueryLog.MaxEntries <= 0 {
c.QueryLog.MaxEntries = d.QueryLog.MaxEntries
}
if c.QueryLog.FlushInterval <= 0 {
c.QueryLog.FlushInterval = d.QueryLog.FlushInterval
}
if c.QueryLog.LiveBuffer <= 0 {
c.QueryLog.LiveBuffer = d.QueryLog.LiveBuffer
}
if c.QueryLog.SampleRate <= 0 {
c.QueryLog.SampleRate = 1
}
if c.QueryLog.SampleRate > 1 {
c.QueryLog.SampleRate = 1
}
if c.Cluster.Valkey.Prefix == "" {
c.Cluster.Valkey.Prefix = d.Cluster.Valkey.Prefix
}
if c.Cluster.Valkey.LeaderTTL <= 0 {
c.Cluster.Valkey.LeaderTTL = d.Cluster.Valkey.LeaderTTL
}
if c.Cluster.PeerTTL <= 0 {
c.Cluster.PeerTTL = d.Cluster.PeerTTL
}
if len(c.API.Listen) == 0 && len(c.API.TLSListen) == 0 {
c.API.Listen = d.API.Listen
}
if c.API.BasePath == "" {
c.API.BasePath = d.API.BasePath
}
c.API.BasePath = "/" + strings.Trim(c.API.BasePath, "/")
if c.API.Auth.Mode == "" {
c.API.Auth.Mode = d.API.Auth.Mode
}
if c.API.Auth.SessionTTL <= 0 {
c.API.Auth.SessionTTL = d.API.Auth.SessionTTL
}
if c.Metrics.Path == "" {
c.Metrics.Path = d.Metrics.Path
}
if !strings.HasPrefix(c.Metrics.Path, "/") {
c.Metrics.Path = "/" + c.Metrics.Path
}
if c.Log.Level == "" {
c.Log.Level = d.Log.Level
}
if c.Log.Format == "" {
c.Log.Format = d.Log.Format
}
// Normalise list sources and upstream weights.
for i := range c.Block.Lists {
l := &c.Block.Lists[i]
if l.Format == "" {
l.Format = "auto"
}
if l.Refresh == 0 {
l.Refresh = Duration(24 * time.Hour)
}
if l.Timeout <= 0 {
l.Timeout = c.Server.Timeouts.List
}
if l.MaxBytes <= 0 {
l.MaxBytes = 512 << 20
}
}
for i := range c.Upstream.Servers {
u := &c.Upstream.Servers[i]
if u.Proto == "" {
u.Proto = "udp"
}
if u.Weight <= 0 {
u.Weight = 1
}
if _, _, err := net.SplitHostPort(u.Addr); err != nil {
u.Addr = net.JoinHostPort(u.Addr, defaultPort(u.Proto))
}
}
}
func defaultPort(proto string) string {
switch proto {
case "tls":
return "853"
case "tcp", "udp":
return "53"
}
return "53"
}
func pow2(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}
// EDNS is a tiny accessor so callers can treat a possibly-zero value safely.
func (c *Config) EDNS() EDNS { return c.DNS.EDNS }
func (c *Config) EDNSBits() bool { return c.DNS.EDNS.ClientSubnet }
// Validate reports every problem it can find, aggregated, so an operator
// fixing a config file sees all of it at once.
func (c *Config) Validate() error {
var errs []error
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if c.Server.MaxConcurrent <= 0 {
add("server.max_concurrent must be > 0")
}
checkAddrs := func(field string, list []string) {
for _, a := range list {
if _, _, err := net.SplitHostPort(a); err != nil {
add("%s: %q is not host:port", field, a)
}
}
}
checkAddrs("dns.udp", c.DNS.UDP)
checkAddrs("dns.tcp", c.DNS.TCP)
checkAddrs("dns.dot.listen", c.DNS.DoT.Listen)
checkAddrs("dns.doh.listen", c.DNS.DoH.Listen)
checkAddrs("api.listen", c.API.Listen)
checkAddrs("api.tls_listen", c.API.TLSListen)
if c.DNS.MaxUDPSize < 512 || c.DNS.MaxUDPSize > 4096 {
add("dns.max_udp_size must be between 512 and 4096")
}
switch c.Upstream.Policy {
case "parallel", "random", "failover", "":
default:
add("upstream.policy must be parallel, random or failover (got %q)", c.Upstream.Policy)
}
if len(c.Upstream.Servers) == 0 {
add("upstream.servers must not be empty")
}
seen := map[string]bool{}
for i, u := range c.Upstream.Servers {
if u.Addr == "" {
add("upstream.servers[%d]: addr is required", i)
}
if seen[u.Addr+"/"+u.Proto] {
add("upstream.servers[%d]: duplicate %s %s", i, u.Proto, u.Addr)
}
seen[u.Addr+"/"+u.Proto] = true
switch u.Proto {
case "udp", "tcp", "tls":
default:
add("upstream.servers[%d]: proto must be udp, tcp or tls (got %q)", i, u.Proto)
}
if u.Proto == "tls" && u.ServerName == "" {
host, _, _ := net.SplitHostPort(u.Addr)
u.ServerName = host
}
}
for i, b := range c.Upstream.Bootstraps {
if b.Name == "" {
add("upstream.bootstraps[%d]: name is required", i)
}
for _, a := range b.Addresses {
if net.ParseIP(a) == nil {
add("upstream.bootstraps[%d]: %q is not an IP address", i, a)
}
}
}
switch c.Block.DefaultAction {
case "nxdomain", "zero", "redirect", "drop", "refused":
default:
add("blocklist.default_action must be nxdomain, zero, redirect, drop or refused (got %q)", c.Block.DefaultAction)
}
if c.Block.DefaultAction == "zero" {
if c.Block.ResponseIP == "" {
c.Block.ResponseIP = "0.0.0.0"
}
if net.ParseIP(c.Block.ResponseIP) == nil {
add("blocklist.response_ip %q is not an IP", c.Block.ResponseIP)
}
if c.Block.ResponseIPv6 == "" {
c.Block.ResponseIPv6 = "::"
}
if net.ParseIP(c.Block.ResponseIPv6) == nil {
add("blocklist.response_ipv6 %q is not an IP", c.Block.ResponseIPv6)
}
}
if c.Block.DefaultAction == "redirect" && c.Block.RedirectName == "" {
add("blocklist.redirect_name is required when default_action is redirect")
}
names := map[string]bool{}
for i, l := range c.Block.Lists {
if l.Name == "" {
add("blocklist.lists[%d]: name is required", i)
}
if names[l.Name] {
add("blocklist.lists[%d]: duplicate list name %q", i, l.Name)
}
names[l.Name] = true
if l.URL == "" && l.Path == "" {
add("blocklist.lists[%d] %q: one of url or path is required", i, l.Name)
}
if l.URL != "" {
if !strings.HasPrefix(l.URL, "https://") && !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "file://") {
add("blocklist.lists[%d] %q: url must be http(s) or file", i, l.Name)
}
if strings.HasPrefix(l.URL, "http://") && l.InsecureSkipTLSVerify == false && false {
add("blocklist.lists[%d] %q: plain http is discouraged", i, l.Name)
}
}
switch l.Format {
case "auto", "hosts", "adblock", "plain", "rpz", "":
default:
add("blocklist.lists[%d] %q: unknown format %q", i, l.Name, l.Format)
}
if l.Refresh < 0 {
add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name)
}
}
if c.Block.Enabled {
if len(c.Block.Lists) == 0 && len(c.Block.LocalZones) == 0 &&
len(c.Block.BlockEntries) == 0 && len(c.Block.AllowEntries) == 0 {
add("blocklist is enabled but no lists, local_zones or inline entries are configured")
}
}
switch c.ACL.DefaultPolicy {
case "allow", "deny", "block_only":
default:
add("acl.default_policy must be allow, deny or block_only (got %q)", c.ACL.DefaultPolicy)
}
groups := map[string]bool{}
for i, g := range c.ACL.Groups {
if g.Name == "" {
add("acl.groups[%d]: name is required", i)
}
if groups[g.Name] {
add("acl.groups[%d]: duplicate group name %q", i, g.Name)
}
groups[g.Name] = true
switch g.Policy {
case "allow", "deny", "block_only":
default:
add("acl.groups[%d] %q: policy must be allow, deny or block_only (got %q)", i, g.Name, g.Policy)
}
for _, cidr := range g.Match.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
add("acl.groups[%d] %q: bad CIDR %q", i, g.Name, cidr)
}
}
for _, s := range g.Match.PTRSuffixes {
if !strings.HasSuffix(s, ".") {
s += "."
g.Match.PTRSuffixes[i] = s
}
}
if g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
add("acl.groups[%d] %q: match.any is set but is also the fallback; make it the last group", i, g.Name)
}
}
if c.QueryLog.SampleRate < 0 || c.QueryLog.SampleRate > 1 {
add("qlog.sample_rate must be between 0 and 1")
}
if c.Cluster.Enabled {
if len(c.Cluster.Valkey.Addrs) == 0 {
add("cluster.valkey.addrs must not be empty when cluster.enabled is true")
}
}
switch c.API.Auth.Mode {
case "none", "apikey", "basic":
default:
add("api.auth.mode must be none, apikey or basic (got %q)", c.API.Auth.Mode)
}
if c.API.Auth.Mode == "apikey" && len(c.API.Auth.APIKeys) == 0 && !c.API.Auth.TrustLoopback {
add("api.auth.mode is apikey but api.auth.api_keys is empty and trust_loopback is false: the API would be unauthenticated")
}
if c.API.Auth.Mode == "basic" && c.API.Auth.Username == "" {
add("api.auth.mode is basic but api.auth.username is empty")
}
if c.API.Auth.SessionTTL <= 0 {
add("api.auth.session_ttl must be > 0")
}
for _, p := range c.API.TrustedProxies {
if _, _, err := net.ParseCIDR(p); err != nil {
add("api.trusted_proxies: bad CIDR %q", p)
}
}
switch c.Log.Level {
case "debug", "info", "warn", "error":
default:
add("log.level must be debug, info, warn or error (got %q)", c.Log.Level)
}
switch c.Log.Format {
case "text", "json":
default:
add("log.format must be text or json (got %q)", c.Log.Format)
}
if len(errs) > 0 {
sort.SliceStable(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
return errors.Join(errs...)
}
return nil
}
// Save atomically writes the configuration back to path as YAML.
func (c *Config) Save(path string) error {
data, err := Marshal(c)
if err != nil {
return err
}
return WriteFileAtomic(path, data, 0o600)
}
// Marshal renders the config as YAML with a leading header comment.
func Marshal(c *Config) ([]byte, error) {
var sb strings.Builder
sb.WriteString("# fancy-dns configuration\n")
sb.WriteString("# Managed with care: the admin API rewrites this file, so keep it in git.\n")
enc := yaml.NewEncoder(&sb)
enc.SetIndent(2)
if err := enc.Encode(c); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return []byte(sb.String()), nil
}
// WriteFileAtomic writes data to path via a temp file + rename so readers
// never observe a partial document.
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, perm); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// AbsPath resolves p against the config's working directory when relative.
func (c *Config) AbsPath(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
wd := c.Server.WorkingDir
if wd == "" {
wd = "."
}
return filepath.Join(wd, p)
}
// CachePath returns the resolved bbolt cache path.
func (c *Config) CachePath() string {
if c.Cache.Persist.Path != "" {
return c.AbsPath(c.Cache.Persist.Path)
}
return c.AbsPath("data/cache.db")
}
// QueryLogPath returns the resolved bbolt query-log path.
func (c *Config) QueryLogPath() string {
if c.QueryLog.Path != "" {
return c.AbsPath(c.QueryLog.Path)
}
return c.AbsPath("data/queries.db")
}
// ListPath returns the on-disk path for a list source, or "" when the source
// is URL-only.
func (c *Config) ListPath(l ListSource) string {
if l.Path == "" {
return ""
}
return c.AbsPath(l.Path)
}
// HasAPIAuth reports whether any authentication is enforced at all.
func (c *Config) HasAPIAuth() bool {
switch c.API.Auth.Mode {
case "none":
return false
case "apikey":
return len(c.API.Auth.APIKeys) > 0 || len(c.API.Auth.ReadOnlyAPIKeys) > 0 || !c.API.Auth.TrustLoopback
case "basic":
return true
}
return false
}
// ClientIPs parses ACL group CIDRs into prefixes, skipping invalid input
// (Validate already reported them).
func (g Group) Prefixes() []netip.Prefix {
out := make([]netip.Prefix, 0, len(g.Match.CIDRs))
for _, c := range g.Match.CIDRs {
p, err := netip.ParsePrefix(c)
if err != nil {
continue
}
out = append(out, p)
}
return out
}