Files
fancy-dns/internal/config/config.go
T
mats 2348d2e4b4
build / build-push (push) Successful in 30s
add
2026-09-27 22:32:03 +02:00

1329 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"`
// 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
}