The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
1338 lines
44 KiB
Go
1338 lines
44 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"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Duration is a time.Duration that unmarshals from a YAML/JSON string such as
|
|
// "30s" or "1h30m". A bare integer is interpreted as seconds.
|
|
type Duration time.Duration
|
|
|
|
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
|
var s string
|
|
if err := value.Decode(&s); err == nil {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
*d = 0
|
|
return nil
|
|
}
|
|
// Bare numbers are seconds, matching the convention used by the
|
|
// surrounding YAML (and by CoreDNS-ish configs people copy around).
|
|
if n, err := time.ParseDuration(s); err == nil {
|
|
*d = Duration(n)
|
|
return nil
|
|
}
|
|
// time.ParseDuration insists on a unit, so the numeric seconds forms
|
|
// (7, 1.5) have to be handled here; without this the documented "a
|
|
// bare integer is seconds" rule never worked, because yaml decodes
|
|
// every scalar into a string and the old float fallback was
|
|
// unreachable.
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
ns := f * float64(time.Second)
|
|
if ns >= float64(math.MaxInt64) || ns < float64(math.MinInt64) {
|
|
return fmt.Errorf("invalid duration %q: out of range", s)
|
|
}
|
|
*d = Duration(time.Duration(ns))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid duration %q", s)
|
|
}
|
|
// Not a scalar at all (mapping/sequence): nothing sensible to interpret.
|
|
return fmt.Errorf("invalid duration: %s", value.Value)
|
|
}
|
|
|
|
// MarshalJSON writes the Go duration string ("30s"), matching MarshalYAML and
|
|
// what the console's types declare. Without it a config.Duration on the wire
|
|
// became a nanosecond count, so `bootstraps[].refresh` arrived as 30000000000
|
|
// and the console's duration field was nonsense.
|
|
func (d Duration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(time.Duration(d).String())
|
|
}
|
|
|
|
// UnmarshalJSON accepts the duration string the server writes, and a bare
|
|
// number as SECONDS — not nanoseconds, which is what encoding/json would do by
|
|
// default and what nobody means. Accepting the number keeps a hand-written
|
|
// client (`{"healthcheck_interval": 30}`) working instead of silently meaning
|
|
// 30ns.
|
|
func (d *Duration) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err == nil {
|
|
v, err := time.ParseDuration(strings.TrimSpace(s))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration %q: %w", s, err)
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
var n float64
|
|
if err := json.Unmarshal(b, &n); err != nil {
|
|
return fmt.Errorf("duration must be a string or a number of seconds: %s", b)
|
|
}
|
|
*d = Duration(n * float64(time.Second))
|
|
return nil
|
|
}
|
|
|
|
func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil }
|
|
|
|
func (d Duration) D() time.Duration { return time.Duration(d) }
|
|
|
|
// Config is the root document. Every section has sane defaults applied by
|
|
// Default() so that a minimal config file is still a working server.
|
|
type Config struct {
|
|
// Version is bumped by the admin API when it rewrites the file; it is
|
|
// echoed in /api/config and used to detect divergence in the cluster.
|
|
Version int64 `yaml:"version,omitempty"`
|
|
|
|
Server Server `yaml:"server"`
|
|
DNS DNS `yaml:"dns"`
|
|
Upstream Upstream `yaml:"upstream"`
|
|
Cache Cache `yaml:"cache"`
|
|
Block Block `yaml:"blocklist"`
|
|
ACL ACL `yaml:"acl"`
|
|
QueryLog QueryLog `yaml:"qlog"`
|
|
Cluster Cluster `yaml:"cluster"`
|
|
API API `yaml:"api"`
|
|
Metrics Metrics `yaml:"metrics"`
|
|
Log Log `yaml:"log"`
|
|
Memory Memory `yaml:"memory"`
|
|
}
|
|
|
|
// Server groups the process-level knobs.
|
|
type Server struct {
|
|
// MaxConcurrent caps in-flight upstream resolutions; excess queries are
|
|
// answered with SERVFAIL rather than queueing without bound.
|
|
MaxConcurrent int `yaml:"max_concurrent"`
|
|
// MaxConcurrentPerClient mirrors MaxConcurrent but per client IP. 0 = off.
|
|
MaxConcurrentPerClient int `yaml:"max_concurrent_per_client"`
|
|
Timeouts Timeout `yaml:"timeouts"`
|
|
// WorkingDir is only informational (shown in the UI).
|
|
WorkingDir string `yaml:"working_dir,omitempty"`
|
|
}
|
|
|
|
type Timeout struct {
|
|
UDP Duration `yaml:"udp"`
|
|
TCP Duration `yaml:"tcp"`
|
|
TLS Duration `yaml:"tls"`
|
|
Query Duration `yaml:"query"`
|
|
Upstre Duration `yaml:"upstream"`
|
|
List Duration `yaml:"list_fetch"`
|
|
}
|
|
|
|
// DNS describes every listener the server exposes.
|
|
type DNS struct {
|
|
UDP []string `yaml:"udp"`
|
|
TCP []string `yaml:"tcp"`
|
|
DoT DoT `yaml:"dot"`
|
|
DoH DoH `yaml:"doh"`
|
|
EDNS EDNS `yaml:"edns0"`
|
|
// ServeStale answers from cache after an upstream failure.
|
|
ServeStale Duration `yaml:"serve_stale"`
|
|
// MaxUDPSize is what we advertise as our own EDNS0 buffer size.
|
|
MaxUDPSize uint16 `yaml:"max_udp_size"`
|
|
// Truncate policy: "question" sets TC and returns the header+question.
|
|
Recursion bool `yaml:"recursion_desired"`
|
|
// HideIdentity stops leaking our version string in CHAOS/bind.
|
|
HideIdentity bool `yaml:"hide_identity"`
|
|
}
|
|
|
|
type DoT struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Listen []string `yaml:"listen"`
|
|
CertFile string `yaml:"cert_file"`
|
|
KeyFile string `yaml:"key_file"`
|
|
// ClientCA enables mTLS when set; clients must then present a cert.
|
|
ClientCA string `yaml:"client_ca"`
|
|
// MinVersion is "1.2" or "1.3".
|
|
MinVersion string `yaml:"min_version"`
|
|
// Upstream makes our own outgoing queries encrypted (RFC 7858, 8.4.1).
|
|
Upstream bool `yaml:"upstream"`
|
|
}
|
|
|
|
type DoH struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Listen []string `yaml:"listen"`
|
|
CertFile string `yaml:"cert_file"`
|
|
KeyFile string `yaml:"key_file"`
|
|
BasePath string `yaml:"base_path"`
|
|
// EchConfig is an optional ECHConfigList to serve over HTTPS (RFC 9460).
|
|
MinVersion string `yaml:"min_version"`
|
|
// AllowPost enables the RFC 8484 POST profile in addition to GET.
|
|
AllowPost bool `yaml:"allow_post"`
|
|
Transport Transport `yaml:"transport"`
|
|
}
|
|
|
|
// Transport selects between HTTP/2 (h2) and HTTP/3 (h3) for DoH.
|
|
type Transport struct {
|
|
// "auto" upgrades to HTTP/3 when the listener is UDP, else h2.
|
|
Mode string `yaml:"mode"`
|
|
// AltSvc lets the listener advertise h3 via Alt-Svc.
|
|
AltSvc string `yaml:"alt_svc"`
|
|
}
|
|
|
|
type EDNS struct {
|
|
// ClientSubnet enables RFC 7871 EDNS-Client-Subnet passthrough.
|
|
ClientSubnet bool `yaml:"client_subnet"`
|
|
IPv4Bits uint8 `yaml:"ipv4_bits"`
|
|
IPv6Bits uint8 `yaml:"ipv6_bits"`
|
|
// MaxUDPSize overrides the advertised EDNS0 buffer for our own answers.
|
|
CookieSupport bool `yaml:"cookie_support"`
|
|
// PreserveEDNS0 copies the incoming OPT options into upstream queries.
|
|
PreserveEDNS0 bool `yaml:"preserve"`
|
|
}
|
|
|
|
type Upstream struct {
|
|
// Servers is the upstream pool.
|
|
Servers []UpstreamServer `yaml:"servers"`
|
|
// Policy is "parallel" (query all, first answer wins), "random" (pick N)
|
|
// or "failover" (walk the list in order).
|
|
Policy string `yaml:"policy"`
|
|
// MaxAttempts bounds how many upstreams are tried per query.
|
|
MaxAttempts int `yaml:"max_attempts"`
|
|
// Healthcheck interval; 0 disables active health probing.
|
|
HealthCheck Duration `yaml:"healthcheck_interval"`
|
|
// CircuitBreaker trips an upstream after this many consecutive failures.
|
|
BreakerThreshold int `yaml:"breaker_threshold"`
|
|
BreakerCooldown Duration `yaml:"breaker_cooldown"`
|
|
// Bootstraps resolves upstream hostnames at startup and on change.
|
|
Bootstraps []UpstreamBootstrap `yaml:"bootstraps"`
|
|
}
|
|
|
|
// UpstreamServer is one resolver. The json tags exist for the same reason as
|
|
// config.Group's: PUT /api/upstreams decodes the console's payload with
|
|
// DisallowUnknownFields, and the console round-trips the whole server object
|
|
// it read from GET /api/upstreams — including the runtime health fields the
|
|
// server owns. Accepting them (and ignoring them) is the contract; refusing
|
|
// them made every save in the console answer 400.
|
|
type UpstreamServer struct {
|
|
// Addr is host:port; host may be an IP or a name resolved via Bootstraps.
|
|
Addr string `yaml:"addr" json:"addr"`
|
|
// Proto is "udp", "tcp" or "tls" (DoT, 8.4.1).
|
|
Proto string `yaml:"proto" json:"proto"`
|
|
// ServerName / InsecureSkipVerify are only used with proto: tls.
|
|
ServerName string `yaml:"server_name" json:"server_name"`
|
|
InsecureSkipVerify bool `yaml:"insecure_skip_verify" json:"insecure_skip_verify"`
|
|
// HealthPath is a hostname used for health probes instead of the Addr host.
|
|
HealthName string `yaml:"health_name" json:"health_name"`
|
|
// Weight biases random selection (default 1).
|
|
Weight int `yaml:"weight" json:"weight"`
|
|
// MaxFails before the breaker opens; 0 uses the global threshold.
|
|
MaxFails int `yaml:"max_fails" json:"max_fails"`
|
|
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
|
|
// Health and the counters below are read-only runtime state. They are
|
|
// accepted on a write and ignored, because the server owns them; the
|
|
// console sends back the object it read.
|
|
Health string `yaml:"-" json:"health,omitempty"`
|
|
LatencyMS float64 `yaml:"-" json:"latency_ms,omitempty"`
|
|
AvgMS float64 `yaml:"-" json:"avg_ms,omitempty"`
|
|
P95MS float64 `yaml:"-" json:"p95_ms,omitempty"`
|
|
FailRate float64 `yaml:"-" json:"fail_rate,omitempty"`
|
|
Queries uint64 `yaml:"-" json:"queries,omitempty"`
|
|
Failures uint64 `yaml:"-" json:"failures,omitempty"`
|
|
BreakerOpen bool `yaml:"-" json:"breaker_open,omitempty"`
|
|
LastError string `yaml:"-" json:"last_error,omitempty"`
|
|
}
|
|
|
|
type UpstreamBootstrap struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Addresses []string `yaml:"addresses" json:"addresses"`
|
|
// Refresh re-resolves periodically (for dyn names).
|
|
Refresh Duration `yaml:"refresh" json:"refresh"`
|
|
}
|
|
|
|
type Cache struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// Size is the in-memory entry budget for the LRU.
|
|
Size int `yaml:"size"`
|
|
// Shards must be a power of two; 0 picks 32.
|
|
Shards int `yaml:"shards"`
|
|
// MinTTL/MaxTTL clamp the TTLs taken from upstream answers.
|
|
MinTTL Duration `yaml:"min_ttl"`
|
|
MaxTTL Duration `yaml:"max_ttl"`
|
|
// ServeStale keeps expired entries around for this long so a dead
|
|
// upstream does not turn into a latency cliff.
|
|
ServeStale Duration `yaml:"serve_stale"`
|
|
// Prefetch keeps ServeStale windows warm.
|
|
Prefetch bool `yaml:"prefetch"`
|
|
// Persist writes a snapshot to bbolt so restarts stay warm.
|
|
Persist CachePersist `yaml:"persist"`
|
|
// Prewarm is a list of "domain/type" (e.g. "example.com/A") to load at boot.
|
|
Prewarm []string `yaml:"prewarm"`
|
|
// Negative caches NXDOMAIN/NODATA, bounded by the SOA minimum.
|
|
NegativeTTL Duration `yaml:"negative_ttl"`
|
|
}
|
|
|
|
type CachePersist struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// Path is the bbolt file; defaults under the data directory.
|
|
Path string `yaml:"path"`
|
|
// Interval is the snapshot write cadence.
|
|
Interval Duration `yaml:"interval"`
|
|
// MaxEntries caps the persisted snapshot (0 = no cap).
|
|
MaxEntries int `yaml:"max_entries"`
|
|
// LoadOnStart reads a snapshot from disk at boot.
|
|
LoadOnStart bool `yaml:"load_on_start"`
|
|
}
|
|
|
|
type Block struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// DefaultAction is "nxdomain", "zero", "redirect" or "drop".
|
|
DefaultAction string `yaml:"default_action"`
|
|
// ResponseIP/ResponseIPv6 are used by the "zero" action.
|
|
ResponseIP string `yaml:"response_ip"`
|
|
ResponseIPv6 string `yaml:"response_ipv6"`
|
|
ResponseTTL Duration `yaml:"response_ttl"`
|
|
// ResponseName/CNAME are used by the "redirect" action.
|
|
RedirectName string `yaml:"redirect_name"`
|
|
// LocalZones are DNS master files (RFC 1035) holding RPZ-style policy
|
|
// records and/or locally defined zones.
|
|
LocalZones []string `yaml:"local_zones"`
|
|
// ManagedZones is the directory the admin API may write local zone
|
|
// files into: one <apex>.zone per zone, holding the SOA, the apex NS
|
|
// set and whatever records the console created. It is the only zone
|
|
// data the console owns — the files in LocalZones are the operator's and
|
|
// are reported read-only.
|
|
//
|
|
// Empty means the console's zone pages are read-only, so a deployment
|
|
// that never sets it cannot have zone data rewritten behind its back.
|
|
ManagedZones string `yaml:"managed_zones"`
|
|
// Lists are remote or on-disk sources of block data.
|
|
Lists []ListSource `yaml:"lists"`
|
|
// AllowEntries and BlockEntries are inline rules (a list of DNS names or
|
|
// master-file lines), useful for small static policies.
|
|
AllowEntries []string `yaml:"allow_entries"`
|
|
BlockEntries []string `yaml:"block_entries"`
|
|
// Parallelism for list parsing/compilation.
|
|
Parallelism int `yaml:"parallelism"`
|
|
}
|
|
|
|
type ListSource struct {
|
|
Name string `yaml:"name"`
|
|
// Format is auto|hosts|adblock|plain|rpz.
|
|
Format string `yaml:"format"`
|
|
// URL and Path: at least one is required. URL wins when both are set; the
|
|
// downloaded copy is written to Path so restarts work without network.
|
|
URL string `yaml:"url"`
|
|
Path string `yaml:"path"`
|
|
Mirror []string `yaml:"mirror"`
|
|
Enabled bool `yaml:"enabled"`
|
|
// Refresh interval; 0 disables scheduled refresh (manual only).
|
|
Refresh Duration `yaml:"refresh"`
|
|
// Timeout for the fetch.
|
|
Timeout Duration `yaml:"timeout"`
|
|
// MaxBytes guards against a runaway/hostile list.
|
|
MaxBytes int64 `yaml:"max_bytes"`
|
|
// ETag / LastModified enable conditional refreshes.
|
|
ETag string `yaml:"etag"`
|
|
LastModified string `yaml:"last_modified"`
|
|
// Category tags feed the UI breakdown.
|
|
Categories []string `yaml:"categories"`
|
|
// InsecureSkipTLSVerify is only for self-hosted mirrors.
|
|
InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"`
|
|
// LastGood keeps the previous compiled set if a refresh fails.
|
|
Comment string `yaml:"comment,omitempty"`
|
|
}
|
|
|
|
type ACL struct {
|
|
// DefaultPolicy is "allow", "deny" or "block_only".
|
|
DefaultPolicy string `yaml:"default_policy"`
|
|
// DefaultRateLimit is queries/second per client IP; 0 = unlimited.
|
|
DefaultRateLimit int `yaml:"default_rate_limit"`
|
|
Groups []Group `yaml:"groups"`
|
|
}
|
|
|
|
// Group is a named set of clients sharing a policy. Groups are evaluated
|
|
// top-to-bottom; the first match wins.
|
|
//
|
|
// The json tags are the same as the yaml ones and they are not decoration:
|
|
// PUT /api/clients decodes the admin console's payload into this struct with
|
|
// DisallowUnknownFields, and the console sends snake_case. A struct with only
|
|
// yaml tags made every write answer 400 `json: unknown field "ptr_suffixes"`.
|
|
type Group struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
|
|
Match Match `yaml:"match" json:"match"`
|
|
// Policy is "allow" (full resolution), "block_only" (blocklists still
|
|
// apply, everything else resolves) or "deny" (REFUSED).
|
|
Policy string `yaml:"policy" json:"policy"`
|
|
// AllowDomains / DenyDomains are per-group overrides; the names support
|
|
// the same wildcards as blocklists ("*.example.com", "**.example.com").
|
|
AllowDomains []string `yaml:"allow_domains" json:"allow_domains"`
|
|
DenyDomains []string `yaml:"deny_domains" json:"deny_domains"`
|
|
// BlocklistToggle lets a group opt out of blocklist filtering.
|
|
SkipBlocklist bool `yaml:"skip_blocklist" json:"skip_blocklist"`
|
|
// CacheToggle lets a group bypass the cache (fresh lookups only).
|
|
SkipCache bool `yaml:"skip_cache" json:"skip_cache"`
|
|
// RateLimit overrides ACL.DefaultRateLimit for this group.
|
|
RateLimit int `yaml:"rate_limit" json:"rate_limit"`
|
|
// MaxConcurrent overrides Server.MaxConcurrentPerClient.
|
|
MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"`
|
|
// LogQueries forces query logging for this group regardless of sampling.
|
|
LogQueries bool `yaml:"log_queries" json:"log_queries"`
|
|
Upstreams []string `yaml:"upstreams" json:"upstreams"`
|
|
}
|
|
|
|
// Match selects clients by network identity.
|
|
type Match struct {
|
|
// CIDRs are matched against the client address (v4 or v6).
|
|
CIDRs []string `yaml:"cidrs" json:"cidrs"`
|
|
// Networks match DNS clients identified by a resolved PTR of their
|
|
// reverse name; the name must end with one of the suffixes.
|
|
PTRSuffixes []string `yaml:"ptr_suffixes" json:"ptr_suffixes"`
|
|
// Tags are matched against the client tag set derived from PTR labels,
|
|
// e.g. "laptop" for "laptop.lan.internal.".
|
|
Tags []string `yaml:"tags" json:"tags"`
|
|
// Always matches (useful as a catch-all fallback group).
|
|
Any bool `yaml:"any" json:"any"`
|
|
}
|
|
|
|
type QueryLog struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// Path is the bbolt file backing the query log ring.
|
|
Path string `yaml:"path"`
|
|
// MaxEntries bounds the ring; oldest are dropped.
|
|
MaxEntries int `yaml:"max_entries"`
|
|
// FlushInterval batches writes.
|
|
FlushInterval Duration `yaml:"flush_interval"`
|
|
// SampleRate is 0..1 for non-blocked, non-error queries.
|
|
SampleRate float64 `yaml:"sample_rate"`
|
|
// What to record.
|
|
LogBlocked bool `yaml:"log_blocked"`
|
|
LogCached bool `yaml:"log_cached"`
|
|
LogErrors bool `yaml:"log_errors"`
|
|
LogClients bool `yaml:"log_client_names"`
|
|
// LiveBuffer is the ring of recent entries kept in memory for the UI/SSE.
|
|
LiveBuffer int `yaml:"live_buffer"`
|
|
// Retention of aggregated per-domain counters.
|
|
TopDomains int `yaml:"top_domains"`
|
|
TopTTL Duration `yaml:"top_ttl"`
|
|
}
|
|
|
|
type Cluster struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// NodeID defaults to hostname; instances use it to elect a refresh leader.
|
|
NodeID string `yaml:"node_id"`
|
|
// Advertise is the address other instances dial for the admin API.
|
|
Advertise string `yaml:"advertise"`
|
|
// PeerSync makes this instance push its config file to peers.
|
|
PeerSync bool `yaml:"peer_sync"`
|
|
PeerTTL Duration `yaml:"peer_ttl"`
|
|
Valkey Valkey `yaml:"valkey"`
|
|
}
|
|
|
|
type Valkey struct {
|
|
// Addrs is a list of host:port.
|
|
Addrs []string `yaml:"addrs"`
|
|
// Username/Password for ACL-based auth.
|
|
Username string `yaml:"username"`
|
|
Password string `yaml:"password"`
|
|
DB int `yaml:"db"`
|
|
Prefix string `yaml:"prefix"`
|
|
TLS bool `yaml:"tls"`
|
|
PoolSize int `yaml:"pool_size"`
|
|
// DialTimeout/ReadTimeout bound cluster operations.
|
|
DialTimeout Duration `yaml:"dial_timeout"`
|
|
ReadTimeout Duration `yaml:"read_timeout"`
|
|
// LeaderTTL is the lease length for list-refresh leadership.
|
|
LeaderTTL Duration `yaml:"leader_ttl"`
|
|
}
|
|
|
|
type API struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
// Listen is the plain HTTP admin/API listener.
|
|
Listen []string `yaml:"listen"`
|
|
// TLSListen serves the admin API (and UI) over TLS instead.
|
|
TLSListen []string `yaml:"tls_listen"`
|
|
CertFile string `yaml:"cert_file"`
|
|
KeyFile string `yaml:"key_file"`
|
|
BasePath string `yaml:"base_path"`
|
|
Auth Auth `yaml:"auth"`
|
|
RateLimit AuthRate `yaml:"rate_limit"`
|
|
// UI toggles the embedded admin console.
|
|
UI bool `yaml:"ui"`
|
|
// AllowConfigWrite lets the API rewrite the config file.
|
|
AllowConfigWrite bool `yaml:"allow_config_write"`
|
|
// AllowBlocklistWrite lets the API write blocklist files to disk.
|
|
AllowBlocklistWrite bool `yaml:"allow_blocklist_write"`
|
|
// TrustedProxies is the set of proxies allowed to set X-Forwarded-For.
|
|
TrustedProxies []string `yaml:"trusted_proxies"`
|
|
// CORSOrigins; "*" is allowed but discouraged.
|
|
CORSOrigins []string `yaml:"cors_origins"`
|
|
// Pprof mounts net/http/pprof on the admin listener for profiling a
|
|
// running server. It is refused unless every admin address is loopback,
|
|
// because the handlers take no API key and pprof/cmdline leaks the
|
|
// command line.
|
|
Pprof bool `yaml:"pprof"`
|
|
}
|
|
|
|
type Auth struct {
|
|
// Mode is "none", "apikey", "basic" or "oidc".
|
|
Mode string `yaml:"mode"`
|
|
// OIDC settings validate access tokens issued by an external provider.
|
|
OIDCIssuer string `yaml:"oidc_issuer"`
|
|
OIDCAudience string `yaml:"oidc_audience"`
|
|
OIDCClientID string `yaml:"oidc_client_id"`
|
|
OIDCWriteRoles []string `yaml:"oidc_write_roles"`
|
|
// APIKeys grant full access.
|
|
APIKeys []string `yaml:"api_keys"`
|
|
// ReadOnlyAPIKeys may only call GET endpoints.
|
|
ReadOnlyAPIKeys []string `yaml:"read_only_api_keys"`
|
|
// Basic user; Password may be plaintext (bcrypt-hashed at load time) or a
|
|
// bcrypt hash (prefix "$2").
|
|
Username string `yaml:"username"`
|
|
Password string `yaml:"password"`
|
|
SessionTTL Duration `yaml:"session_ttl"`
|
|
// TrustLoopback skips auth for requests from 127.0.0.0/8 and ::1.
|
|
TrustLoopback bool `yaml:"trust_loopback"`
|
|
}
|
|
|
|
type AuthRate struct {
|
|
// Requests per minute per IP for the login endpoint.
|
|
PerMinute int `yaml:"per_minute"`
|
|
// Failures per minute per IP before a temporary lockout.
|
|
Failures int `yaml:"failures"`
|
|
Lockout Duration `yaml:"lockout"`
|
|
}
|
|
|
|
type Metrics struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Path string `yaml:"path"`
|
|
// Protect requires the API token for /metrics.
|
|
Protect bool `yaml:"protect"`
|
|
}
|
|
|
|
type Log struct {
|
|
// Level is debug|info|warn|error.
|
|
Level string `yaml:"level"`
|
|
// Format is text|json.
|
|
Format string `yaml:"format"`
|
|
// Addr overrides the log destination; empty means stderr.
|
|
Addr string `yaml:"addr"`
|
|
}
|
|
|
|
// Default returns a fully-populated configuration. Callers are expected to
|
|
// unmarshal the user file on top of it, then Validate.
|
|
func Default() *Config {
|
|
return &Config{
|
|
Server: Server{
|
|
MaxConcurrent: 1024,
|
|
MaxConcurrentPerClient: 0,
|
|
Timeouts: Timeout{
|
|
UDP: Duration(2 * time.Second),
|
|
TCP: Duration(4 * time.Second),
|
|
TLS: Duration(4 * time.Second),
|
|
Query: Duration(5 * time.Second),
|
|
Upstre: Duration(2 * time.Second),
|
|
List: Duration(60 * time.Second),
|
|
},
|
|
},
|
|
DNS: DNS{
|
|
UDP: []string{"0.0.0.0:53", "[::]:53"},
|
|
TCP: []string{"0.0.0.0:53", "[::]:53"},
|
|
MaxUDPSize: 1232,
|
|
Recursion: true,
|
|
DoT: DoT{MinVersion: "1.2", Listen: []string{"0.0.0.0:853"}},
|
|
DoH: DoH{BasePath: "/dns-query", MinVersion: "1.2", AllowPost: true, Transport: Transport{Mode: "auto"}, Listen: []string{"0.0.0.0:443"}},
|
|
EDNS: EDNS{ClientSubnet: true, IPv4Bits: 24, IPv6Bits: 56, PreserveEDNS0: true},
|
|
ServeStale: Duration(0),
|
|
},
|
|
Upstream: Upstream{
|
|
Policy: "parallel",
|
|
Servers: []UpstreamServer{
|
|
{Addr: "9.9.9.9:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"},
|
|
{Addr: "149.112.112.112:53", Proto: "udp", Weight: 1, HealthName: "dns.quad9.net"},
|
|
{Addr: "1.1.1.1:53", Proto: "tls", ServerName: "cloudflare-dns.com", Weight: 1, HealthName: "cloudflare.com"},
|
|
{Addr: "9.9.9.9:853", Proto: "tls", ServerName: "dns.quad9.net", Weight: 1, HealthName: "dns.quad9.net"},
|
|
{Addr: "1.0.0.1:53", Proto: "udp", Weight: 1, HealthName: "one.one.one.one"},
|
|
},
|
|
MaxAttempts: 3,
|
|
HealthCheck: Duration(30 * time.Second),
|
|
BreakerThreshold: 5,
|
|
BreakerCooldown: Duration(30 * time.Second),
|
|
},
|
|
Cache: Cache{
|
|
Enabled: true,
|
|
Size: 200000,
|
|
Shards: 32,
|
|
MinTTL: Duration(0),
|
|
MaxTTL: Duration(time.Hour),
|
|
ServeStale: Duration(30 * time.Minute),
|
|
Prefetch: true,
|
|
NegativeTTL: Duration(30 * time.Minute),
|
|
Persist: CachePersist{
|
|
Enabled: true,
|
|
Interval: Duration(30 * time.Second),
|
|
MaxEntries: 500000,
|
|
LoadOnStart: true,
|
|
},
|
|
},
|
|
Block: Block{
|
|
Enabled: true,
|
|
DefaultAction: "nxdomain",
|
|
ResponseIP: "0.0.0.0",
|
|
ResponseIPv6: "::",
|
|
ResponseTTL: Duration(60 * time.Second),
|
|
Parallelism: 4,
|
|
},
|
|
ACL: ACL{
|
|
DefaultPolicy: "allow",
|
|
},
|
|
QueryLog: QueryLog{
|
|
Enabled: true,
|
|
MaxEntries: 200000,
|
|
FlushInterval: Duration(time.Second),
|
|
SampleRate: 1,
|
|
LogBlocked: true,
|
|
LogCached: true,
|
|
LogErrors: true,
|
|
LiveBuffer: 2000,
|
|
TopDomains: 5000,
|
|
TopTTL: Duration(5 * time.Minute),
|
|
},
|
|
Cluster: Cluster{
|
|
Enabled: false,
|
|
PeerTTL: Duration(15 * time.Second),
|
|
Valkey: Valkey{
|
|
Prefix: "fancydns",
|
|
PoolSize: 10,
|
|
DialTimeout: Duration(2 * time.Second),
|
|
ReadTimeout: Duration(2 * time.Second),
|
|
LeaderTTL: Duration(30 * time.Second),
|
|
},
|
|
},
|
|
API: API{
|
|
Enabled: true,
|
|
Listen: []string{"127.0.0.1:8080"},
|
|
BasePath: "/api",
|
|
UI: true,
|
|
AllowConfigWrite: true,
|
|
AllowBlocklistWrite: true,
|
|
Auth: Auth{
|
|
Mode: "apikey",
|
|
SessionTTL: Duration(12 * time.Hour),
|
|
TrustLoopback: true,
|
|
},
|
|
RateLimit: AuthRate{PerMinute: 60, Failures: 10, Lockout: Duration(5 * time.Minute)},
|
|
},
|
|
Metrics: Metrics{Enabled: true, Path: "/metrics"},
|
|
Log: Log{Level: "info", Format: "text"},
|
|
}
|
|
}
|
|
|
|
// Load reads path (if non-empty) on top of Default and validates the result.
|
|
func Load(path string) (*Config, error) {
|
|
cfg := Default()
|
|
if path != "" {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
raw, err = expandEnv(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config %s: %w", path, err)
|
|
}
|
|
if err := yaml.Unmarshal(raw, cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
|
}
|
|
cfg.applyDefaults()
|
|
cfg.WorkingDirOrSet(filepath.Dir(path))
|
|
} else {
|
|
cfg.WorkingDirOrSet(".")
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// expandEnv substitutes ${NAME} and ${NAME:-default} in a config document
|
|
// before it is parsed, so a secret can come from the environment (a
|
|
// Secret-mounted env var in Kubernetes, for instance) instead of being
|
|
// committed next to the rest of the config.
|
|
//
|
|
// Three rules, each of which exists because breaking it is a real footgun:
|
|
//
|
|
// - Only the braced forms are touched, so a bare `$` in a URL or a
|
|
// blocklist rule survives verbatim.
|
|
// - Comments are copied through untouched. A config that *documents* the
|
|
// syntax ("# api_keys: ${FANCYDNS_API_KEY}") must still load.
|
|
// - An unset variable with no default is an error, not an empty string:
|
|
// `api_keys: ["${FANCYDNS_API_KEY}"]` silently expanding to `[""]` is how
|
|
// a deployment ends up with no authentication at all.
|
|
func expandEnv(raw []byte) ([]byte, error) {
|
|
if !bytes.Contains(raw, []byte("${")) {
|
|
return raw, nil
|
|
}
|
|
var b bytes.Buffer
|
|
b.Grow(len(raw))
|
|
// Line-at-a-time so the comment state can be tracked: a `#` only opens a
|
|
// comment outside quotes, and only when it starts the line or follows
|
|
// whitespace. That is YAML's rule, and a config with `#` inside a quoted
|
|
// string must not be truncated at it.
|
|
for off := 0; off < len(raw); {
|
|
line := raw[off:]
|
|
if nl := bytes.IndexByte(line, '\n'); nl >= 0 {
|
|
line = line[:nl+1]
|
|
}
|
|
err := expandLine(line, &b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("line %d: %w", bytes.Count(raw[:off], []byte("\n"))+1, err)
|
|
}
|
|
off += len(line)
|
|
}
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
// expandLine appends one line to b, substituting the ${...} references it
|
|
// finds outside comments.
|
|
//
|
|
// The scan tracks quotes so that two rules hold at once: a `#` only opens a
|
|
// comment outside quotes and only at the start of a line or after whitespace
|
|
// (YAML's rule, so a `#` inside a quoted string is data, not a comment), and a
|
|
// reference inside a quoted scalar — which is how `api_keys: ["${KEY}"]` is
|
|
// naturally written — is expanded rather than copied through.
|
|
func expandLine(line []byte, b *bytes.Buffer) error {
|
|
var inSingle, inDouble, escaped, inComment bool
|
|
for i := 0; i < len(line); {
|
|
c := line[i]
|
|
if inComment {
|
|
b.WriteByte(c)
|
|
i++
|
|
continue
|
|
}
|
|
// An escape is consumed before the reference check below, or
|
|
// "\${FOO}" would still expand.
|
|
if escaped {
|
|
escaped = false
|
|
b.WriteByte(c)
|
|
i++
|
|
continue
|
|
}
|
|
// References expand wherever they are not in a comment, quotes
|
|
// included: the whole point of the feature is that
|
|
// `api_keys: ["${KEY}"]` is how an operator would naturally write it.
|
|
// Quote tracking exists only to decide whether a '#' opens a comment.
|
|
if c == '$' && i+1 < len(line) && line[i+1] == '{' {
|
|
end := bytes.IndexByte(line[i+2:], '}')
|
|
if end < 0 {
|
|
return fmt.Errorf("unterminated ${ at byte %d", i)
|
|
}
|
|
spec := string(line[i+2 : i+2+end])
|
|
i += end + 3
|
|
|
|
name, def := spec, ""
|
|
hasDefault := false
|
|
// colonDefault marks the ":-" form, which substitutes for an empty
|
|
// value as well as an unset one (POSIX). A bare "-" default applies
|
|
// only when the variable is unset.
|
|
colonDefault := false
|
|
switch k := strings.Index(spec, ":-"); {
|
|
case k >= 0:
|
|
name, def, hasDefault, colonDefault = spec[:k], spec[k+2:], true, true
|
|
case strings.IndexByte(spec, '-') >= 0:
|
|
// A bare `-` is a default too, for an unset (not an empty)
|
|
// variable. It splits on the FIRST hyphen, so a variable name
|
|
// may not contain one.
|
|
k := strings.IndexByte(spec, '-')
|
|
name, def, hasDefault = spec[:k], spec[k+1:], true
|
|
}
|
|
if name == "" {
|
|
return fmt.Errorf("empty variable name in ${%s}", spec)
|
|
}
|
|
v, isSet := os.LookupEnv(name)
|
|
switch {
|
|
case isSet && (v != "" || !hasDefault || !colonDefault):
|
|
// A variable that is set but empty expands to empty. That is
|
|
// the whole difference between ${X}, ${X-y} and ${X:-y}, and
|
|
// getting it wrong turns an intentionally blank secret into a
|
|
// startup failure.
|
|
b.WriteString(v)
|
|
case hasDefault:
|
|
b.WriteString(def)
|
|
default:
|
|
return fmt.Errorf("environment variable %s is not set (use ${%s:-default} to give it a fallback)", name, name)
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case inDouble:
|
|
switch {
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inDouble = false
|
|
}
|
|
case inSingle:
|
|
if c == '\'' {
|
|
inSingle = false
|
|
}
|
|
case c == '#' && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t'):
|
|
inComment = true
|
|
case c == '"':
|
|
inDouble = true
|
|
case c == '\'':
|
|
inSingle = true
|
|
}
|
|
b.WriteByte(c)
|
|
i++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WorkingDirOrSet records the config directory so relative paths in the
|
|
// document resolve consistently regardless of the process cwd.
|
|
func (c *Config) WorkingDirOrSet(dir string) {
|
|
if c.Server.WorkingDir == "" {
|
|
c.Server.WorkingDir = dir
|
|
}
|
|
}
|
|
|
|
// applyDefaults backfills values that a hand-written file may omit, and
|
|
// normalises "off"/"disabled"/"" style booleans.
|
|
func (c *Config) applyDefaults() {
|
|
d := Default()
|
|
if c.Server.MaxConcurrent <= 0 {
|
|
c.Server.MaxConcurrent = d.Server.MaxConcurrent
|
|
}
|
|
if c.Server.Timeouts.UDP <= 0 {
|
|
c.Server.Timeouts.UDP = d.Server.Timeouts.UDP
|
|
}
|
|
if c.Server.Timeouts.TCP <= 0 {
|
|
c.Server.Timeouts.TCP = d.Server.Timeouts.TCP
|
|
}
|
|
if c.Server.Timeouts.TLS <= 0 {
|
|
c.Server.Timeouts.TLS = d.Server.Timeouts.TLS
|
|
}
|
|
if c.Server.Timeouts.Query <= 0 {
|
|
c.Server.Timeouts.Query = d.Server.Timeouts.Query
|
|
}
|
|
if c.Server.Timeouts.Upstre <= 0 {
|
|
c.Server.Timeouts.Upstre = d.Server.Timeouts.Upstre
|
|
}
|
|
if c.Server.Timeouts.List <= 0 {
|
|
c.Server.Timeouts.List = d.Server.Timeouts.List
|
|
}
|
|
if c.DNS.MaxUDPSize < 512 {
|
|
c.DNS.MaxUDPSize = d.DNS.MaxUDPSize
|
|
}
|
|
if c.DNS.DoH.BasePath == "" {
|
|
c.DNS.DoH.BasePath = d.DNS.DoH.BasePath
|
|
}
|
|
if c.DNS.DoT.MinVersion == "" {
|
|
c.DNS.DoT.MinVersion = d.DNS.DoT.MinVersion
|
|
}
|
|
if c.DNS.DoH.MinVersion == "" {
|
|
c.DNS.DoH.MinVersion = d.DNS.DoH.MinVersion
|
|
}
|
|
if c.DNS.DoH.Transport.Mode == "" {
|
|
c.DNS.DoH.Transport.Mode = "auto"
|
|
}
|
|
if c.EDNSBits() {
|
|
// nothing; kept for symmetry
|
|
}
|
|
if c.EDNS().IPv4Bits == 0 {
|
|
c.DNS.EDNS.IPv4Bits = d.DNS.EDNS.IPv4Bits
|
|
}
|
|
if c.DNS.EDNS.IPv6Bits == 0 {
|
|
c.DNS.EDNS.IPv6Bits = d.DNS.EDNS.IPv6Bits
|
|
}
|
|
if c.Upstream.Policy == "" {
|
|
c.Upstream.Policy = d.Upstream.Policy
|
|
}
|
|
if c.Upstream.MaxAttempts <= 0 {
|
|
c.Upstream.MaxAttempts = d.Upstream.MaxAttempts
|
|
}
|
|
if c.Upstream.BreakerThreshold <= 0 {
|
|
c.Upstream.BreakerThreshold = d.Upstream.BreakerThreshold
|
|
}
|
|
if c.Upstream.BreakerCooldown <= 0 {
|
|
c.Upstream.BreakerCooldown = d.Upstream.BreakerCooldown
|
|
}
|
|
if c.Cache.Size <= 0 {
|
|
c.Cache.Size = d.Cache.Size
|
|
}
|
|
if c.Cache.Shards <= 0 {
|
|
c.Cache.Shards = d.Cache.Shards
|
|
}
|
|
c.Cache.Shards = pow2(c.Cache.Shards)
|
|
if c.Cache.MaxTTL <= 0 {
|
|
c.Cache.MaxTTL = d.Cache.MaxTTL
|
|
}
|
|
if c.Cache.NegativeTTL <= 0 {
|
|
c.Cache.NegativeTTL = d.Cache.NegativeTTL
|
|
}
|
|
if c.Cache.Persist.Interval <= 0 {
|
|
c.Cache.Persist.Interval = d.Cache.Persist.Interval
|
|
}
|
|
if c.Block.DefaultAction == "" {
|
|
c.Block.DefaultAction = d.Block.DefaultAction
|
|
}
|
|
if c.Block.ResponseTTL <= 0 {
|
|
c.Block.ResponseTTL = d.Block.ResponseTTL
|
|
}
|
|
if c.Block.Parallelism <= 0 {
|
|
c.Block.Parallelism = d.Block.Parallelism
|
|
}
|
|
if c.ACL.DefaultPolicy == "" {
|
|
c.ACL.DefaultPolicy = d.ACL.DefaultPolicy
|
|
}
|
|
if c.QueryLog.MaxEntries <= 0 {
|
|
c.QueryLog.MaxEntries = d.QueryLog.MaxEntries
|
|
}
|
|
if c.QueryLog.FlushInterval <= 0 {
|
|
c.QueryLog.FlushInterval = d.QueryLog.FlushInterval
|
|
}
|
|
if c.QueryLog.LiveBuffer <= 0 {
|
|
c.QueryLog.LiveBuffer = d.QueryLog.LiveBuffer
|
|
}
|
|
if c.QueryLog.SampleRate <= 0 {
|
|
c.QueryLog.SampleRate = 1
|
|
}
|
|
if c.QueryLog.SampleRate > 1 {
|
|
c.QueryLog.SampleRate = 1
|
|
}
|
|
if c.Cluster.Valkey.Prefix == "" {
|
|
c.Cluster.Valkey.Prefix = d.Cluster.Valkey.Prefix
|
|
}
|
|
if c.Cluster.Valkey.LeaderTTL <= 0 {
|
|
c.Cluster.Valkey.LeaderTTL = d.Cluster.Valkey.LeaderTTL
|
|
}
|
|
if c.Cluster.PeerTTL <= 0 {
|
|
c.Cluster.PeerTTL = d.Cluster.PeerTTL
|
|
}
|
|
if len(c.API.Listen) == 0 && len(c.API.TLSListen) == 0 {
|
|
c.API.Listen = d.API.Listen
|
|
}
|
|
if c.API.BasePath == "" {
|
|
c.API.BasePath = d.API.BasePath
|
|
}
|
|
c.API.BasePath = "/" + strings.Trim(c.API.BasePath, "/")
|
|
if c.API.Auth.Mode == "" {
|
|
c.API.Auth.Mode = d.API.Auth.Mode
|
|
}
|
|
if c.API.Auth.SessionTTL <= 0 {
|
|
c.API.Auth.SessionTTL = d.API.Auth.SessionTTL
|
|
}
|
|
if c.Metrics.Path == "" {
|
|
c.Metrics.Path = d.Metrics.Path
|
|
}
|
|
if !strings.HasPrefix(c.Metrics.Path, "/") {
|
|
c.Metrics.Path = "/" + c.Metrics.Path
|
|
}
|
|
if c.Log.Level == "" {
|
|
c.Log.Level = d.Log.Level
|
|
}
|
|
if c.Log.Format == "" {
|
|
c.Log.Format = d.Log.Format
|
|
}
|
|
// Normalise list sources and upstream weights.
|
|
for i := range c.Block.Lists {
|
|
l := &c.Block.Lists[i]
|
|
if l.Format == "" {
|
|
l.Format = "auto"
|
|
}
|
|
if l.Refresh == 0 {
|
|
l.Refresh = Duration(24 * time.Hour)
|
|
}
|
|
if l.Timeout <= 0 {
|
|
l.Timeout = c.Server.Timeouts.List
|
|
}
|
|
if l.MaxBytes <= 0 {
|
|
l.MaxBytes = 512 << 20
|
|
}
|
|
}
|
|
for i := range c.Upstream.Servers {
|
|
u := &c.Upstream.Servers[i]
|
|
if u.Proto == "" {
|
|
u.Proto = "udp"
|
|
}
|
|
if u.Weight <= 0 {
|
|
u.Weight = 1
|
|
}
|
|
if _, _, err := net.SplitHostPort(u.Addr); err != nil {
|
|
u.Addr = net.JoinHostPort(u.Addr, defaultPort(u.Proto))
|
|
}
|
|
}
|
|
}
|
|
|
|
func defaultPort(proto string) string {
|
|
switch proto {
|
|
case "tls":
|
|
return "853"
|
|
case "tcp", "udp":
|
|
return "53"
|
|
}
|
|
return "53"
|
|
}
|
|
|
|
func pow2(n int) int {
|
|
if n <= 1 {
|
|
return 1
|
|
}
|
|
p := 1
|
|
for p < n {
|
|
p <<= 1
|
|
}
|
|
return p
|
|
}
|
|
|
|
// EDNS is a tiny accessor so callers can treat a possibly-zero value safely.
|
|
func (c *Config) EDNS() EDNS { return c.DNS.EDNS }
|
|
func (c *Config) EDNSBits() bool { return c.DNS.EDNS.ClientSubnet }
|
|
|
|
// Validate reports every problem it can find, aggregated, so an operator
|
|
// fixing a config file sees all of it at once.
|
|
func (c *Config) Validate() error {
|
|
var errs []error
|
|
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
|
|
|
|
if c.Server.MaxConcurrent <= 0 {
|
|
add("server.max_concurrent must be > 0")
|
|
}
|
|
checkAddrs := func(field string, list []string) {
|
|
for _, a := range list {
|
|
if _, _, err := net.SplitHostPort(a); err != nil {
|
|
add("%s: %q is not host:port", field, a)
|
|
}
|
|
}
|
|
}
|
|
checkAddrs("dns.udp", c.DNS.UDP)
|
|
checkAddrs("dns.tcp", c.DNS.TCP)
|
|
checkAddrs("dns.dot.listen", c.DNS.DoT.Listen)
|
|
checkAddrs("dns.doh.listen", c.DNS.DoH.Listen)
|
|
checkAddrs("api.listen", c.API.Listen)
|
|
checkAddrs("api.tls_listen", c.API.TLSListen)
|
|
|
|
if c.DNS.MaxUDPSize < 512 || c.DNS.MaxUDPSize > 4096 {
|
|
add("dns.max_udp_size must be between 512 and 4096")
|
|
}
|
|
switch c.Upstream.Policy {
|
|
case "parallel", "random", "failover", "":
|
|
default:
|
|
add("upstream.policy must be parallel, random or failover (got %q)", c.Upstream.Policy)
|
|
}
|
|
if len(c.Upstream.Servers) == 0 {
|
|
add("upstream.servers must not be empty")
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, u := range c.Upstream.Servers {
|
|
if u.Addr == "" {
|
|
add("upstream.servers[%d]: addr is required", i)
|
|
}
|
|
if seen[u.Addr+"/"+u.Proto] {
|
|
add("upstream.servers[%d]: duplicate %s %s", i, u.Proto, u.Addr)
|
|
}
|
|
seen[u.Addr+"/"+u.Proto] = true
|
|
switch u.Proto {
|
|
case "udp", "tcp", "tls":
|
|
default:
|
|
add("upstream.servers[%d]: proto must be udp, tcp or tls (got %q)", i, u.Proto)
|
|
}
|
|
if u.Proto == "tls" && u.ServerName == "" {
|
|
host, _, _ := net.SplitHostPort(u.Addr)
|
|
u.ServerName = host
|
|
}
|
|
}
|
|
for i, b := range c.Upstream.Bootstraps {
|
|
if b.Name == "" {
|
|
add("upstream.bootstraps[%d]: name is required", i)
|
|
}
|
|
for _, a := range b.Addresses {
|
|
if net.ParseIP(a) == nil {
|
|
add("upstream.bootstraps[%d]: %q is not an IP address", i, a)
|
|
}
|
|
}
|
|
}
|
|
|
|
switch c.Block.DefaultAction {
|
|
case "nxdomain", "zero", "redirect", "drop", "refused":
|
|
default:
|
|
add("blocklist.default_action must be nxdomain, zero, redirect, drop or refused (got %q)", c.Block.DefaultAction)
|
|
}
|
|
if c.Block.DefaultAction == "zero" {
|
|
if c.Block.ResponseIP == "" {
|
|
c.Block.ResponseIP = "0.0.0.0"
|
|
}
|
|
if net.ParseIP(c.Block.ResponseIP) == nil {
|
|
add("blocklist.response_ip %q is not an IP", c.Block.ResponseIP)
|
|
}
|
|
if c.Block.ResponseIPv6 == "" {
|
|
c.Block.ResponseIPv6 = "::"
|
|
}
|
|
if net.ParseIP(c.Block.ResponseIPv6) == nil {
|
|
add("blocklist.response_ipv6 %q is not an IP", c.Block.ResponseIPv6)
|
|
}
|
|
}
|
|
if c.Block.DefaultAction == "redirect" && c.Block.RedirectName == "" {
|
|
add("blocklist.redirect_name is required when default_action is redirect")
|
|
}
|
|
names := map[string]bool{}
|
|
for i, l := range c.Block.Lists {
|
|
if l.Name == "" {
|
|
add("blocklist.lists[%d]: name is required", i)
|
|
}
|
|
if names[l.Name] {
|
|
add("blocklist.lists[%d]: duplicate list name %q", i, l.Name)
|
|
}
|
|
names[l.Name] = true
|
|
if l.URL == "" && l.Path == "" {
|
|
add("blocklist.lists[%d] %q: one of url or path is required", i, l.Name)
|
|
}
|
|
if l.URL != "" {
|
|
if !strings.HasPrefix(l.URL, "https://") && !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "file://") {
|
|
add("blocklist.lists[%d] %q: url must be http(s) or file", i, l.Name)
|
|
}
|
|
if strings.HasPrefix(l.URL, "http://") && l.InsecureSkipTLSVerify == false && false {
|
|
add("blocklist.lists[%d] %q: plain http is discouraged", i, l.Name)
|
|
}
|
|
}
|
|
switch l.Format {
|
|
case "auto", "hosts", "adblock", "plain", "rpz", "":
|
|
default:
|
|
add("blocklist.lists[%d] %q: unknown format %q", i, l.Name, l.Format)
|
|
}
|
|
if l.Refresh < 0 {
|
|
add("blocklist.lists[%d] %q: refresh must not be negative", i, l.Name)
|
|
}
|
|
}
|
|
if c.Block.Enabled {
|
|
if len(c.Block.Lists) == 0 && len(c.Block.LocalZones) == 0 &&
|
|
len(c.Block.BlockEntries) == 0 && len(c.Block.AllowEntries) == 0 {
|
|
add("blocklist is enabled but no lists, local_zones or inline entries are configured")
|
|
}
|
|
}
|
|
|
|
switch c.ACL.DefaultPolicy {
|
|
case "allow", "deny", "block_only":
|
|
default:
|
|
add("acl.default_policy must be allow, deny or block_only (got %q)", c.ACL.DefaultPolicy)
|
|
}
|
|
groups := map[string]bool{}
|
|
for i, g := range c.ACL.Groups {
|
|
if g.Name == "" {
|
|
add("acl.groups[%d]: name is required", i)
|
|
}
|
|
if groups[g.Name] {
|
|
add("acl.groups[%d]: duplicate group name %q", i, g.Name)
|
|
}
|
|
groups[g.Name] = true
|
|
switch g.Policy {
|
|
case "allow", "deny", "block_only":
|
|
default:
|
|
add("acl.groups[%d] %q: policy must be allow, deny or block_only (got %q)", i, g.Name, g.Policy)
|
|
}
|
|
for _, cidr := range g.Match.CIDRs {
|
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
|
add("acl.groups[%d] %q: bad CIDR %q", i, g.Name, cidr)
|
|
}
|
|
}
|
|
for _, s := range g.Match.PTRSuffixes {
|
|
if !strings.HasSuffix(s, ".") {
|
|
s += "."
|
|
g.Match.PTRSuffixes[i] = s
|
|
}
|
|
}
|
|
if g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
|
|
add("acl.groups[%d] %q: match.any is set but is also the fallback; make it the last group", i, g.Name)
|
|
}
|
|
}
|
|
if c.QueryLog.SampleRate < 0 || c.QueryLog.SampleRate > 1 {
|
|
add("qlog.sample_rate must be between 0 and 1")
|
|
}
|
|
|
|
if c.Cluster.Enabled {
|
|
if len(c.Cluster.Valkey.Addrs) == 0 {
|
|
add("cluster.valkey.addrs must not be empty when cluster.enabled is true")
|
|
}
|
|
}
|
|
switch c.API.Auth.Mode {
|
|
case "none", "apikey", "basic", "oidc":
|
|
default:
|
|
add("api.auth.mode must be none, apikey, basic or oidc (got %q)", c.API.Auth.Mode)
|
|
}
|
|
if c.API.Auth.Mode == "apikey" && len(c.API.Auth.APIKeys) == 0 && !c.API.Auth.TrustLoopback {
|
|
add("api.auth.mode is apikey but api.auth.api_keys is empty and trust_loopback is false: the API would be unauthenticated")
|
|
}
|
|
if c.API.Auth.Mode == "basic" && c.API.Auth.Username == "" {
|
|
add("api.auth.mode is basic but api.auth.username is empty")
|
|
}
|
|
if c.API.Auth.Mode == "oidc" {
|
|
if c.API.Auth.OIDCIssuer == "" || c.API.Auth.OIDCAudience == "" || c.API.Auth.OIDCClientID == "" {
|
|
add("api.auth.mode is oidc and requires oidc_issuer, oidc_audience and oidc_client_id")
|
|
}
|
|
if c.API.Auth.OIDCIssuer != "" {
|
|
u, err := url.Parse(c.API.Auth.OIDCIssuer)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
|
add("api.auth.oidc_issuer must be an HTTPS issuer URL")
|
|
}
|
|
}
|
|
}
|
|
if c.API.Auth.SessionTTL <= 0 {
|
|
add("api.auth.session_ttl must be > 0")
|
|
}
|
|
for _, p := range c.API.TrustedProxies {
|
|
if _, _, err := net.ParseCIDR(p); err != nil {
|
|
add("api.trusted_proxies: bad CIDR %q", p)
|
|
}
|
|
}
|
|
|
|
switch c.Log.Level {
|
|
case "debug", "info", "warn", "error":
|
|
default:
|
|
add("log.level must be debug, info, warn or error (got %q)", c.Log.Level)
|
|
}
|
|
switch c.Log.Format {
|
|
case "text", "json":
|
|
default:
|
|
add("log.format must be text or json (got %q)", c.Log.Format)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
sort.SliceStable(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
|
|
return errors.Join(errs...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Save atomically writes the configuration back to path as YAML.
|
|
func (c *Config) Save(path string) error {
|
|
data, err := Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WriteFileAtomic(path, data, 0o600)
|
|
}
|
|
|
|
// Marshal renders the config as YAML with a leading header comment.
|
|
func Marshal(c *Config) ([]byte, error) {
|
|
var sb strings.Builder
|
|
sb.WriteString("# fancy-dns configuration\n")
|
|
sb.WriteString("# Managed with care: the admin API rewrites this file, so keep it in git.\n")
|
|
enc := yaml.NewEncoder(&sb)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(c); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(sb.String()), nil
|
|
}
|
|
|
|
// WriteFileAtomic writes data to path via a temp file + rename so readers
|
|
// never observe a partial document.
|
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmpName, perm); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|
|
|
|
// AbsPath resolves p against the config's working directory when relative.
|
|
func (c *Config) AbsPath(p string) string {
|
|
if p == "" || filepath.IsAbs(p) {
|
|
return p
|
|
}
|
|
wd := c.Server.WorkingDir
|
|
if wd == "" {
|
|
wd = "."
|
|
}
|
|
return filepath.Join(wd, p)
|
|
}
|
|
|
|
// CachePath returns the resolved bbolt cache path.
|
|
func (c *Config) CachePath() string {
|
|
if c.Cache.Persist.Path != "" {
|
|
return c.AbsPath(c.Cache.Persist.Path)
|
|
}
|
|
return c.AbsPath("data/cache.db")
|
|
}
|
|
|
|
// QueryLogPath returns the resolved bbolt query-log path.
|
|
func (c *Config) QueryLogPath() string {
|
|
if c.QueryLog.Path != "" {
|
|
return c.AbsPath(c.QueryLog.Path)
|
|
}
|
|
return c.AbsPath("data/queries.db")
|
|
}
|
|
|
|
// ListPath returns the on-disk path for a list source, or "" when the source
|
|
// is URL-only.
|
|
func (c *Config) ListPath(l ListSource) string {
|
|
if l.Path == "" {
|
|
return ""
|
|
}
|
|
return c.AbsPath(l.Path)
|
|
}
|
|
|
|
// HasAPIAuth reports whether any authentication is enforced at all.
|
|
func (c *Config) HasAPIAuth() bool {
|
|
switch c.API.Auth.Mode {
|
|
case "none":
|
|
return false
|
|
case "apikey":
|
|
return len(c.API.Auth.APIKeys) > 0 || len(c.API.Auth.ReadOnlyAPIKeys) > 0 || !c.API.Auth.TrustLoopback
|
|
case "basic":
|
|
return true
|
|
case "oidc":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ClientIPs parses ACL group CIDRs into prefixes, skipping invalid input
|
|
// (Validate already reported them).
|
|
func (g Group) Prefixes() []netip.Prefix {
|
|
out := make([]netip.Prefix, 0, len(g.Match.CIDRs))
|
|
for _, c := range g.Match.CIDRs {
|
|
p, err := netip.ParsePrefix(c)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|