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