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.
1242 lines
39 KiB
Go
1242 lines
39 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 (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/netip"
|
|
"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)
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
// 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" 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)
|
|
}
|
|
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
|
|
}
|
|
switch {
|
|
case inDouble:
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
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
|
|
case 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):
|
|
// v is "" here only for the "-" form with an empty variable,
|
|
// which is the whole difference between ${X-y} and ${X:-y}.
|
|
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
|
|
}
|
|
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":
|
|
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
|
|
}
|