Zones, address redirects, inline rules, lists and groups are cluster objects now (see the previous commits and deploy/MIGRATION.md), so the mechanisms that held them are gone: * internal/kubezones and the resolver's service-account access to a ConfigMap, the address-overrides read/write path and its poller, the zone-file poller and the Local zones editor with its /api/local-zones routes. * localzone.Managed, the directory of console-written master files, and the blocklist.managed_zones key. The resolver now writes no zone data at all; its zone hooks only list. The shared half of that file (the record validator, the renderer and the round-trip check) stays as zonestate.go, because DNSZone rendering is built on it. Its tests are ported to RenderZone, which keeps the coverage of every editable record type, wildcards, relative owners, the SOA/NS rule and the delegation rule. * Tests of the removed write paths: the contract test now builds its zone from a DNSZone, and a new test pins that every zone write route on the resolver is refused and changes nothing. Standalone installs keep editing master files on disk and SIGHUP, and the YAML-backed domain, client, upstream and settings editors, which have not moved. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1375 lines
46 KiB
Go
1375 lines
46 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"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"`
|
|
AccessLog AccessLog `yaml:"access_log"`
|
|
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"`
|
|
}
|
|
|
|
// ForwardZone routes the apex and every name below it exclusively to Servers.
|
|
type ForwardZone struct {
|
|
Zone string `yaml:"zone" json:"zone"`
|
|
Servers []UpstreamServer `yaml:"servers" json:"servers"`
|
|
}
|
|
|
|
type Upstream struct {
|
|
ForwardZones []ForwardZone `yaml:"forward_zones,omitempty" json:"forward_zones,omitempty"`
|
|
// 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"`
|
|
}
|
|
|
|
// AccessLog configures the access log: one JSON line on stdout for each query
|
|
// whose outcome is selected below. Nothing is stored by the resolver; the pod's
|
|
// log collector indexes the lines, and the metrics count everything.
|
|
type AccessLog struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// LogBlocked logs blocklist hits and redirects.
|
|
LogBlocked bool `yaml:"log_blocked"`
|
|
// LogErrors logs SERVFAILs, drops and ACL refusals.
|
|
LogErrors bool `yaml:"log_errors"`
|
|
// LogCached also logs cache hits.
|
|
LogCached bool `yaml:"log_cached"`
|
|
// SampleRate is the fraction of ordinary answers to log: 0 (the default)
|
|
// none, 1 all. It is for temporary debugging; the metrics are the
|
|
// permanent record of ordinary traffic.
|
|
SampleRate float64 `yaml:"sample_rate"`
|
|
// Buffer is how many lines may wait for the log stream before further ones
|
|
// are dropped (and counted). Zero means 4096.
|
|
Buffer int `yaml:"buffer"`
|
|
}
|
|
|
|
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"`
|
|
// Links point the console at the tools that show what the resolver itself
|
|
// does not keep: traffic charts and the query log. All optional.
|
|
Links Links `yaml:"links"`
|
|
}
|
|
|
|
// Links are http(s) URLs the console shows as buttons. The resolver stores no
|
|
// traffic history and no query log, so these are where an operator goes for
|
|
// them (a Grafana dashboard, a log view in Loki).
|
|
type Links struct {
|
|
Grafana string `yaml:"grafana" json:"grafana"`
|
|
Logs string `yaml:"logs" json:"logs"`
|
|
Metrics string `yaml:"metrics" json:"metrics"`
|
|
}
|
|
|
|
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",
|
|
},
|
|
AccessLog: AccessLog{
|
|
Enabled: true,
|
|
LogBlocked: true,
|
|
LogErrors: true,
|
|
},
|
|
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.AccessLog.SampleRate < 0 {
|
|
c.AccessLog.SampleRate = 0
|
|
}
|
|
if c.AccessLog.SampleRate > 1 {
|
|
c.AccessLog.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.ForwardZones {
|
|
z := &c.Upstream.ForwardZones[i]
|
|
z.Zone = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(z.Zone), "."))
|
|
for j := range z.Servers {
|
|
u := &z.Servers[j]
|
|
if u.Proto == "" {
|
|
u.Proto = "udp"
|
|
}
|
|
if u.Weight <= 0 {
|
|
u.Weight = 1
|
|
}
|
|
if u.Addr != "" {
|
|
if _, _, err := net.SplitHostPort(u.Addr); err != nil {
|
|
u.Addr = net.JoinHostPort(u.Addr, defaultPort(u.Proto))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
zones := map[string]bool{}
|
|
for i, z := range c.Upstream.ForwardZones {
|
|
name := strings.ToLower(strings.TrimSuffix(z.Zone, "."))
|
|
if _, ok := dns.IsDomainName(name); !ok || name == "" || strings.Contains(name, "*") {
|
|
add("upstream.forward_zones[%d]: invalid zone %q", i, z.Zone)
|
|
}
|
|
if zones[name] {
|
|
add("upstream.forward_zones[%d]: duplicate zone %q", i, z.Zone)
|
|
}
|
|
zones[name] = true
|
|
if len(z.Servers) == 0 {
|
|
add("upstream.forward_zones[%d]: servers must not be empty", i)
|
|
}
|
|
for _, u := range z.Servers {
|
|
host, port, err := net.SplitHostPort(u.Addr)
|
|
if err != nil || host == "" || port == "" {
|
|
add("upstream.forward_zones[%d]: invalid server address %q", i, u.Addr)
|
|
}
|
|
switch u.Proto {
|
|
case "udp", "tcp", "tls":
|
|
default:
|
|
add("upstream.forward_zones[%d]: invalid proto %q", i, u.Proto)
|
|
}
|
|
}
|
|
}
|
|
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.AccessLog.SampleRate < 0 || c.AccessLog.SampleRate > 1 {
|
|
add("access_log.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")
|
|
}
|
|
}
|
|
for name, link := range map[string]string{
|
|
"grafana": c.API.Links.Grafana, "logs": c.API.Links.Logs, "metrics": c.API.Links.Metrics,
|
|
} {
|
|
// The console renders these as hrefs, so a scheme other than http(s)
|
|
// (javascript:, data:) is refused here rather than trusted there.
|
|
if link != "" && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "http://") {
|
|
add("api.links.%s must be an http(s) URL", name)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|