// 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 ( "bytes" "encoding/json" "errors" "fmt" "math" "net" "net/netip" "net/url" "os" "path/filepath" "sort" "strconv" "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 } // time.ParseDuration insists on a unit, so the numeric seconds forms // (7, 1.5) have to be handled here; without this the documented "a // bare integer is seconds" rule never worked, because yaml decodes // every scalar into a string and the old float fallback was // unreachable. if f, err := strconv.ParseFloat(s, 64); err == nil { ns := f * float64(time.Second) if ns >= float64(math.MaxInt64) || ns < float64(math.MinInt64) { return fmt.Errorf("invalid duration %q: out of range", s) } *d = Duration(time.Duration(ns)) return nil } return fmt.Errorf("invalid duration %q", s) } // Not a scalar at all (mapping/sequence): nothing sensible to interpret. return fmt.Errorf("invalid duration: %s", value.Value) } // MarshalJSON writes the Go duration string ("30s"), matching MarshalYAML and // what the console's types declare. Without it a config.Duration on the wire // became a nanosecond count, so `bootstraps[].refresh` arrived as 30000000000 // and the console's duration field was nonsense. func (d Duration) MarshalJSON() ([]byte, error) { return json.Marshal(time.Duration(d).String()) } // UnmarshalJSON accepts the duration string the server writes, and a bare // number as SECONDS — not nanoseconds, which is what encoding/json would do by // default and what nobody means. Accepting the number keeps a hand-written // client (`{"healthcheck_interval": 30}`) working instead of silently meaning // 30ns. func (d *Duration) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err == nil { v, err := time.ParseDuration(strings.TrimSpace(s)) if err != nil { return fmt.Errorf("invalid duration %q: %w", s, err) } *d = Duration(v) return nil } var n float64 if err := json.Unmarshal(b, &n); err != nil { return fmt.Errorf("duration must be a string or a number of seconds: %s", b) } *d = Duration(n * 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"` Memory Memory `yaml:"memory"` } // 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"` } // UpstreamServer is one resolver. The json tags exist for the same reason as // config.Group's: PUT /api/upstreams decodes the console's payload with // DisallowUnknownFields, and the console round-trips the whole server object // it read from GET /api/upstreams — including the runtime health fields the // server owns. Accepting them (and ignoring them) is the contract; refusing // them made every save in the console answer 400. type UpstreamServer struct { // Addr is host:port; host may be an IP or a name resolved via Bootstraps. Addr string `yaml:"addr" json:"addr"` // Proto is "udp", "tcp" or "tls" (DoT, 8.4.1). Proto string `yaml:"proto" json:"proto"` // ServerName / InsecureSkipVerify are only used with proto: tls. ServerName string `yaml:"server_name" json:"server_name"` InsecureSkipVerify bool `yaml:"insecure_skip_verify" json:"insecure_skip_verify"` // HealthPath is a hostname used for health probes instead of the Addr host. HealthName string `yaml:"health_name" json:"health_name"` // Weight biases random selection (default 1). Weight int `yaml:"weight" json:"weight"` // MaxFails before the breaker opens; 0 uses the global threshold. MaxFails int `yaml:"max_fails" json:"max_fails"` Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` // Health and the counters below are read-only runtime state. They are // accepted on a write and ignored, because the server owns them; the // console sends back the object it read. Health string `yaml:"-" json:"health,omitempty"` LatencyMS float64 `yaml:"-" json:"latency_ms,omitempty"` AvgMS float64 `yaml:"-" json:"avg_ms,omitempty"` P95MS float64 `yaml:"-" json:"p95_ms,omitempty"` FailRate float64 `yaml:"-" json:"fail_rate,omitempty"` Queries uint64 `yaml:"-" json:"queries,omitempty"` Failures uint64 `yaml:"-" json:"failures,omitempty"` BreakerOpen bool `yaml:"-" json:"breaker_open,omitempty"` LastError string `yaml:"-" json:"last_error,omitempty"` } type UpstreamBootstrap struct { Name string `yaml:"name" json:"name"` Addresses []string `yaml:"addresses" json:"addresses"` // Refresh re-resolves periodically (for dyn names). Refresh Duration `yaml:"refresh" json:"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. // // The json tags are the same as the yaml ones and they are not decoration: // PUT /api/clients decodes the admin console's payload into this struct with // DisallowUnknownFields, and the console sends snake_case. A struct with only // yaml tags made every write answer 400 `json: unknown field "ptr_suffixes"`. type Group struct { Name string `yaml:"name" json:"name"` Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` Match Match `yaml:"match" json:"match"` // Policy is "allow" (full resolution), "block_only" (blocklists still // apply, everything else resolves) or "deny" (REFUSED). Policy string `yaml:"policy" json:"policy"` // AllowDomains / DenyDomains are per-group overrides; the names support // the same wildcards as blocklists ("*.example.com", "**.example.com"). AllowDomains []string `yaml:"allow_domains" json:"allow_domains"` DenyDomains []string `yaml:"deny_domains" json:"deny_domains"` // BlocklistToggle lets a group opt out of blocklist filtering. SkipBlocklist bool `yaml:"skip_blocklist" json:"skip_blocklist"` // CacheToggle lets a group bypass the cache (fresh lookups only). SkipCache bool `yaml:"skip_cache" json:"skip_cache"` // RateLimit overrides ACL.DefaultRateLimit for this group. RateLimit int `yaml:"rate_limit" json:"rate_limit"` // MaxConcurrent overrides Server.MaxConcurrentPerClient. MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"` // LogQueries forces query logging for this group regardless of sampling. LogQueries bool `yaml:"log_queries" json:"log_queries"` Upstreams []string `yaml:"upstreams" json:"upstreams"` } // Match selects clients by network identity. type Match struct { // CIDRs are matched against the client address (v4 or v6). CIDRs []string `yaml:"cidrs" json:"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" json:"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" json:"tags"` // Always matches (useful as a catch-all fallback group). Any bool `yaml:"any" json:"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"` // Pprof mounts net/http/pprof on the admin listener for profiling a // running server. It is refused unless every admin address is loopback, // because the handlers take no API key and pprof/cmdline leaks the // command line. Pprof bool `yaml:"pprof"` } type Auth struct { // Mode is "none", "apikey", "basic" or "oidc". Mode string `yaml:"mode"` // OIDC settings validate access tokens issued by an external provider. OIDCIssuer string `yaml:"oidc_issuer"` OIDCAudience string `yaml:"oidc_audience"` OIDCClientID string `yaml:"oidc_client_id"` OIDCWriteRoles []string `yaml:"oidc_write_roles"` // 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) } raw, err = expandEnv(raw) if err != nil { return nil, fmt.Errorf("config %s: %w", path, 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 } // expandEnv substitutes ${NAME} and ${NAME:-default} in a config document // before it is parsed, so a secret can come from the environment (a // Secret-mounted env var in Kubernetes, for instance) instead of being // committed next to the rest of the config. // // Three rules, each of which exists because breaking it is a real footgun: // // - Only the braced forms are touched, so a bare `$` in a URL or a // blocklist rule survives verbatim. // - Comments are copied through untouched. A config that *documents* the // syntax ("# api_keys: ${FANCYDNS_API_KEY}") must still load. // - An unset variable with no default is an error, not an empty string: // `api_keys: ["${FANCYDNS_API_KEY}"]` silently expanding to `[""]` is how // a deployment ends up with no authentication at all. func expandEnv(raw []byte) ([]byte, error) { if !bytes.Contains(raw, []byte("${")) { return raw, nil } var b bytes.Buffer b.Grow(len(raw)) // Line-at-a-time so the comment state can be tracked: a `#` only opens a // comment outside quotes, and only when it starts the line or follows // whitespace. That is YAML's rule, and a config with `#` inside a quoted // string must not be truncated at it. for off := 0; off < len(raw); { line := raw[off:] if nl := bytes.IndexByte(line, '\n'); nl >= 0 { line = line[:nl+1] } err := expandLine(line, &b) if err != nil { return nil, fmt.Errorf("line %d: %w", bytes.Count(raw[:off], []byte("\n"))+1, err) } off += len(line) } return b.Bytes(), nil } // expandLine appends one line to b, substituting the ${...} references it // finds outside comments. // // The scan tracks quotes so that two rules hold at once: a `#` only opens a // comment outside quotes and only at the start of a line or after whitespace // (YAML's rule, so a `#` inside a quoted string is data, not a comment), and a // reference inside a quoted scalar — which is how `api_keys: ["${KEY}"]` is // naturally written — is expanded rather than copied through. func expandLine(line []byte, b *bytes.Buffer) error { var inSingle, inDouble, escaped, inComment bool for i := 0; i < len(line); { c := line[i] if inComment { b.WriteByte(c) i++ continue } // An escape is consumed before the reference check below, or // "\${FOO}" would still expand. if escaped { escaped = false b.WriteByte(c) i++ continue } // References expand wherever they are not in a comment, quotes // included: the whole point of the feature is that // `api_keys: ["${KEY}"]` is how an operator would naturally write it. // Quote tracking exists only to decide whether a '#' opens a comment. if c == '$' && i+1 < len(line) && line[i+1] == '{' { end := bytes.IndexByte(line[i+2:], '}') if end < 0 { return fmt.Errorf("unterminated ${ at byte %d", i) } spec := string(line[i+2 : i+2+end]) i += end + 3 name, def := spec, "" hasDefault := false // colonDefault marks the ":-" form, which substitutes for an empty // value as well as an unset one (POSIX). A bare "-" default applies // only when the variable is unset. colonDefault := false switch k := strings.Index(spec, ":-"); { case k >= 0: name, def, hasDefault, colonDefault = spec[:k], spec[k+2:], true, true case strings.IndexByte(spec, '-') >= 0: // A bare `-` is a default too, for an unset (not an empty) // variable. It splits on the FIRST hyphen, so a variable name // may not contain one. k := strings.IndexByte(spec, '-') name, def, hasDefault = spec[:k], spec[k+1:], true } if name == "" { return fmt.Errorf("empty variable name in ${%s}", spec) } v, isSet := os.LookupEnv(name) switch { case isSet && (v != "" || !hasDefault || !colonDefault): // A variable that is set but empty expands to empty. That is // the whole difference between ${X}, ${X-y} and ${X:-y}, and // getting it wrong turns an intentionally blank secret into a // startup failure. b.WriteString(v) case hasDefault: b.WriteString(def) default: return fmt.Errorf("environment variable %s is not set (use ${%s:-default} to give it a fallback)", name, name) } continue } switch { case inDouble: switch { case c == '\\': escaped = true case c == '"': inDouble = false } case inSingle: if c == '\'' { inSingle = false } case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'): inComment = true case c == '"': inDouble = true case c == '\'': inSingle = true } b.WriteByte(c) i++ } return 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", "oidc": default: add("api.auth.mode must be none, apikey, basic or oidc (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.Mode == "oidc" { if c.API.Auth.OIDCIssuer == "" || c.API.Auth.OIDCAudience == "" || c.API.Auth.OIDCClientID == "" { add("api.auth.mode is oidc and requires oidc_issuer, oidc_audience and oidc_client_id") } if c.API.Auth.OIDCIssuer != "" { u, err := url.Parse(c.API.Auth.OIDCIssuer) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { add("api.auth.oidc_issuer must be an HTTPS issuer URL") } } } 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 case "oidc": 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 }