Files
vibedns/internal/config/settings.go
T
owenandClaude Sonnet 5 4895c8fd1e Add a customizable HTTP/HTTPS block page for sinkholed queries
Serves a page explaining why a domain was blocked instead of leaving a
sinkholed client with a dead connection. Binds its own HTTP/HTTPS
listeners with self-signed, per-hostname TLS certs generated on the
fly, re-evaluates the requesting client against the policy engine per
request, and renders an HTML template editable from Settings with a
live preview.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
2026-08-17 00:24:00 -05:00

713 lines
24 KiB
Go

package config
import (
"fmt"
"net"
"net/netip"
"net/url"
"strconv"
"strings"
)
// Setting keys. Keeping them as constants means a typo is a compile error
// rather than a silently ignored setting.
const (
KeyDNSUDPListen = "dns.udp_listen"
KeyDNSTCPListen = "dns.tcp_listen"
KeyDNSRecursion = "dns.recursion_enabled"
KeyDNSEDNSEnabled = "dns.edns_enabled"
KeyDNSEDNSUDPSize = "dns.edns_udp_size"
KeyDNSDefaultTTL = "dns.default_ttl"
KeyDNSTCPIdle = "dns.tcp_idle_timeout_s"
KeyDNSExposeVer = "dns.expose_version"
KeyDNSMaxUDPSize = "dns.max_udp_response"
KeyResolverUpstreams = "resolver.upstreams"
KeyResolverTimeout = "resolver.timeout_ms"
KeyResolverRetries = "resolver.retries"
KeyResolverStrategy = "resolver.strategy"
KeyResolverAllow = "resolver.allow_networks"
KeyResolverDeny = "resolver.deny_networks"
KeyResolverPreferV6 = "resolver.prefer_ipv6"
KeyResolverDNSSEC = "resolver.dnssec_enabled"
KeyResolverMaxConc = "resolver.max_concurrent"
KeyCacheEnabled = "cache.enabled"
KeyCacheMaxEntries = "cache.max_entries"
KeyCacheMinTTL = "cache.min_ttl"
KeyCacheMaxTTL = "cache.max_ttl"
KeyCacheNegativeTTL = "cache.negative_ttl"
KeyCacheServeStale = "cache.serve_stale"
KeyCacheStaleTTL = "cache.stale_ttl"
KeyCachePrefetch = "cache.prefetch_enabled"
KeyCachePrefetchPct = "cache.prefetch_threshold_pct"
KeyCacheCleanup = "cache.cleanup_interval_s"
KeyQueryLogEnabled = "querylog.enabled"
KeyQueryLogRetention = "querylog.retention_days"
KeyQueryLogMaxRows = "querylog.max_rows"
KeyQueryLogCleanup = "querylog.cleanup_interval_min"
KeyQueryLogIgnoreNet = "querylog.ignore_networks"
KeyQueryLogIgnoreDom = "querylog.ignore_domains"
KeyRateLimitEnabled = "ratelimit.enabled"
KeyRateLimitQPS = "ratelimit.qps"
KeyRateLimitBurst = "ratelimit.burst"
KeyRateLimitExempt = "ratelimit.exempt_networks"
KeyHTTPListen = "http.listen"
KeyHTTPBaseURL = "http.base_url"
KeyHTTPTrustedProxy = "http.trusted_proxies"
KeyHTTPMetrics = "http.metrics_enabled"
KeyHTTPMetricsPublic = "http.metrics_public"
KeyHTTPMaxUploadMB = "http.max_upload_mb"
KeyHTTPRateLimit = "http.rate_limit_per_min"
KeyBackupEnabled = "backup.enabled"
KeyBackupDir = "backup.dir"
KeyBackupInterval = "backup.interval_hours"
KeyBackupRetention = "backup.retention"
KeyLogLevel = "log.level"
KeyLogFormat = "log.format"
KeyAuditMaxRows = "log.audit_max_rows"
KeyBlockPageEnabled = "blockpage.enabled"
KeyBlockPageHTTPListen = "blockpage.http_listen"
KeyBlockPageHTTPSListen = "blockpage.https_listen"
KeyBlockPageHTML = "blockpage.html"
)
// DNSSettings covers the listeners and protocol behaviour.
type DNSSettings struct {
UDPListen string `json:"udp_listen"`
TCPListen string `json:"tcp_listen"`
Recursion bool `json:"recursion_enabled"`
EDNSEnabled bool `json:"edns_enabled"`
EDNSUDPSize int `json:"edns_udp_size"`
MaxUDPResponse int `json:"max_udp_response"`
DefaultTTL uint32 `json:"default_ttl"`
TCPIdleSeconds int `json:"tcp_idle_timeout_s"`
ExposeVersion bool `json:"expose_version"`
}
// ResolverSettings covers upstream forwarding and recursion ACLs.
type ResolverSettings struct {
Upstreams []string `json:"upstreams"`
TimeoutMS int `json:"timeout_ms"`
Retries int `json:"retries"`
Strategy string `json:"strategy"`
AllowNetworks []string `json:"allow_networks"`
DenyNetworks []string `json:"deny_networks"`
PreferIPv6 bool `json:"prefer_ipv6"`
DNSSEC bool `json:"dnssec_enabled"`
MaxConcurrent int `json:"max_concurrent"`
}
// Server selection strategies.
const (
StrategySequential = "sequential"
StrategyRoundRobin = "round_robin"
StrategyRandom = "random"
StrategyFastest = "fastest"
)
// CacheSettings covers the resolver cache.
type CacheSettings struct {
Enabled bool `json:"enabled"`
MaxEntries int `json:"max_entries"`
MinTTL int `json:"min_ttl"`
MaxTTL int `json:"max_ttl"`
NegativeTTL int `json:"negative_ttl"`
ServeStale bool `json:"serve_stale"`
StaleTTL int `json:"stale_ttl"`
Prefetch bool `json:"prefetch_enabled"`
PrefetchPercent int `json:"prefetch_threshold_pct"`
CleanupSeconds int `json:"cleanup_interval_s"`
}
// QueryLogSettings covers DNS query logging and its retention.
type QueryLogSettings struct {
Enabled bool `json:"enabled"`
RetentionDays int `json:"retention_days"`
MaxRows int `json:"max_rows"`
CleanupMinutes int `json:"cleanup_interval_min"`
IgnoreNetworks []string `json:"ignore_networks"`
IgnoreDomains []string `json:"ignore_domains"`
}
// RateLimitSettings covers per-client DNS rate limiting.
type RateLimitSettings struct {
Enabled bool `json:"enabled"`
QPS int `json:"qps"`
Burst int `json:"burst"`
ExemptNetworks []string `json:"exempt_networks"`
}
// HTTPSettings covers the management interface.
type HTTPSettings struct {
Listen string `json:"listen"`
BaseURL string `json:"base_url"`
TrustedProxies []string `json:"trusted_proxies"`
MetricsEnabled bool `json:"metrics_enabled"`
MetricsPublic bool `json:"metrics_public"`
MaxUploadMB int `json:"max_upload_mb"`
RateLimitPerMin int `json:"rate_limit_per_min"`
}
// BackupSettings covers automatic database backups.
type BackupSettings struct {
Enabled bool `json:"enabled"`
Directory string `json:"dir"`
IntervalHours int `json:"interval_hours"`
Retention int `json:"retention"`
}
// LoggingSettings covers application log output.
type LoggingSettings struct {
Level string `json:"level"`
Format string `json:"format"`
AuditMaxRows int `json:"audit_max_rows"`
}
// BlockPageSettings covers the HTTP/HTTPS server that answers sinkholed
// traffic with a page explaining why the request was blocked.
type BlockPageSettings struct {
Enabled bool `json:"enabled"`
HTTPListen string `json:"http_listen"`
HTTPSListen string `json:"https_listen"`
HTML string `json:"html"`
}
// Settings is the complete runtime configuration held in SQLite.
type Settings struct {
DNS DNSSettings `json:"dns"`
Resolver ResolverSettings `json:"resolver"`
Cache CacheSettings `json:"cache"`
QueryLog QueryLogSettings `json:"query_log"`
RateLimit RateLimitSettings `json:"rate_limit"`
HTTP HTTPSettings `json:"http"`
Backup BackupSettings `json:"backup"`
Logging LoggingSettings `json:"logging"`
BlockPage BlockPageSettings `json:"block_page"`
}
// DefaultSettings returns a safe, closed-by-default configuration.
//
// Recursion is enabled but the allow list contains only loopback and private
// address space, so a fresh install is never an open resolver.
func DefaultSettings() Settings {
return Settings{
DNS: DNSSettings{
UDPListen: DefaultDNSAddr,
TCPListen: DefaultDNSAddr,
Recursion: true,
EDNSEnabled: true,
EDNSUDPSize: 1232, // conservative post-DNS-flag-day value
MaxUDPResponse: 1232,
DefaultTTL: 3600,
TCPIdleSeconds: 8,
ExposeVersion: false,
},
Resolver: ResolverSettings{
Upstreams: []string{"1.1.1.1:53", "1.0.0.1:53", "9.9.9.9:53"},
TimeoutMS: 2000,
Retries: 2,
Strategy: StrategyFastest,
AllowNetworks: DefaultPrivateNetworks(),
DenyNetworks: nil,
PreferIPv6: false,
DNSSEC: true,
MaxConcurrent: 256,
},
Cache: CacheSettings{
Enabled: true,
MaxEntries: 100_000,
MinTTL: 5,
MaxTTL: 86400,
NegativeTTL: 900,
ServeStale: true,
StaleTTL: 3600,
Prefetch: true,
PrefetchPercent: 10,
CleanupSeconds: 60,
},
QueryLog: QueryLogSettings{
Enabled: true,
RetentionDays: 7,
MaxRows: 1_000_000,
CleanupMinutes: 30,
},
RateLimit: RateLimitSettings{
Enabled: true,
QPS: 200,
Burst: 400,
ExemptNetworks: []string{"127.0.0.0/8", "::1/128"},
},
HTTP: HTTPSettings{
Listen: DefaultHTTPAddr,
MetricsEnabled: true,
MetricsPublic: false,
MaxUploadMB: 64,
RateLimitPerMin: 600,
},
Backup: BackupSettings{
Enabled: false,
Directory: "./data/backups",
IntervalHours: 24,
Retention: 7,
},
Logging: LoggingSettings{
Level: "info",
Format: "text",
AuditMaxRows: 50_000,
},
BlockPage: BlockPageSettings{
Enabled: false,
HTTPListen: ":80",
HTTPSListen: ":443",
HTML: DefaultBlockPageHTML,
},
}
}
// DefaultPrivateNetworks lists the RFC1918/RFC4193 ranges used as the initial
// recursion ACL.
func DefaultPrivateNetworks() []string {
return []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
}
// LoadSettings overlays stored values on top of the defaults. Unparseable or
// missing values fall back to the default for that field, so a corrupted row
// can never prevent the server from starting.
func LoadSettings(stored map[string]string) Settings {
s := DefaultSettings()
g := getter{stored}
s.DNS.UDPListen = g.str(KeyDNSUDPListen, s.DNS.UDPListen)
s.DNS.TCPListen = g.str(KeyDNSTCPListen, s.DNS.TCPListen)
s.DNS.Recursion = g.boolean(KeyDNSRecursion, s.DNS.Recursion)
s.DNS.EDNSEnabled = g.boolean(KeyDNSEDNSEnabled, s.DNS.EDNSEnabled)
s.DNS.EDNSUDPSize = g.integer(KeyDNSEDNSUDPSize, s.DNS.EDNSUDPSize)
s.DNS.MaxUDPResponse = g.integer(KeyDNSMaxUDPSize, s.DNS.MaxUDPResponse)
s.DNS.DefaultTTL = uint32(g.integer(KeyDNSDefaultTTL, int(s.DNS.DefaultTTL)))
s.DNS.TCPIdleSeconds = g.integer(KeyDNSTCPIdle, s.DNS.TCPIdleSeconds)
s.DNS.ExposeVersion = g.boolean(KeyDNSExposeVer, s.DNS.ExposeVersion)
s.Resolver.Upstreams = g.lines(KeyResolverUpstreams, s.Resolver.Upstreams)
s.Resolver.TimeoutMS = g.integer(KeyResolverTimeout, s.Resolver.TimeoutMS)
s.Resolver.Retries = g.integer(KeyResolverRetries, s.Resolver.Retries)
s.Resolver.Strategy = g.str(KeyResolverStrategy, s.Resolver.Strategy)
s.Resolver.AllowNetworks = g.lines(KeyResolverAllow, s.Resolver.AllowNetworks)
s.Resolver.DenyNetworks = g.lines(KeyResolverDeny, s.Resolver.DenyNetworks)
s.Resolver.PreferIPv6 = g.boolean(KeyResolverPreferV6, s.Resolver.PreferIPv6)
s.Resolver.DNSSEC = g.boolean(KeyResolverDNSSEC, s.Resolver.DNSSEC)
s.Resolver.MaxConcurrent = g.integer(KeyResolverMaxConc, s.Resolver.MaxConcurrent)
s.Cache.Enabled = g.boolean(KeyCacheEnabled, s.Cache.Enabled)
s.Cache.MaxEntries = g.integer(KeyCacheMaxEntries, s.Cache.MaxEntries)
s.Cache.MinTTL = g.integer(KeyCacheMinTTL, s.Cache.MinTTL)
s.Cache.MaxTTL = g.integer(KeyCacheMaxTTL, s.Cache.MaxTTL)
s.Cache.NegativeTTL = g.integer(KeyCacheNegativeTTL, s.Cache.NegativeTTL)
s.Cache.ServeStale = g.boolean(KeyCacheServeStale, s.Cache.ServeStale)
s.Cache.StaleTTL = g.integer(KeyCacheStaleTTL, s.Cache.StaleTTL)
s.Cache.Prefetch = g.boolean(KeyCachePrefetch, s.Cache.Prefetch)
s.Cache.PrefetchPercent = g.integer(KeyCachePrefetchPct, s.Cache.PrefetchPercent)
s.Cache.CleanupSeconds = g.integer(KeyCacheCleanup, s.Cache.CleanupSeconds)
s.QueryLog.Enabled = g.boolean(KeyQueryLogEnabled, s.QueryLog.Enabled)
s.QueryLog.RetentionDays = g.integer(KeyQueryLogRetention, s.QueryLog.RetentionDays)
s.QueryLog.MaxRows = g.integer(KeyQueryLogMaxRows, s.QueryLog.MaxRows)
s.QueryLog.CleanupMinutes = g.integer(KeyQueryLogCleanup, s.QueryLog.CleanupMinutes)
s.QueryLog.IgnoreNetworks = g.lines(KeyQueryLogIgnoreNet, s.QueryLog.IgnoreNetworks)
s.QueryLog.IgnoreDomains = g.lines(KeyQueryLogIgnoreDom, s.QueryLog.IgnoreDomains)
s.RateLimit.Enabled = g.boolean(KeyRateLimitEnabled, s.RateLimit.Enabled)
s.RateLimit.QPS = g.integer(KeyRateLimitQPS, s.RateLimit.QPS)
s.RateLimit.Burst = g.integer(KeyRateLimitBurst, s.RateLimit.Burst)
s.RateLimit.ExemptNetworks = g.lines(KeyRateLimitExempt, s.RateLimit.ExemptNetworks)
s.HTTP.Listen = g.str(KeyHTTPListen, s.HTTP.Listen)
s.HTTP.BaseURL = g.str(KeyHTTPBaseURL, s.HTTP.BaseURL)
s.HTTP.TrustedProxies = g.lines(KeyHTTPTrustedProxy, s.HTTP.TrustedProxies)
s.HTTP.MetricsEnabled = g.boolean(KeyHTTPMetrics, s.HTTP.MetricsEnabled)
s.HTTP.MetricsPublic = g.boolean(KeyHTTPMetricsPublic, s.HTTP.MetricsPublic)
s.HTTP.MaxUploadMB = g.integer(KeyHTTPMaxUploadMB, s.HTTP.MaxUploadMB)
s.HTTP.RateLimitPerMin = g.integer(KeyHTTPRateLimit, s.HTTP.RateLimitPerMin)
s.Backup.Enabled = g.boolean(KeyBackupEnabled, s.Backup.Enabled)
s.Backup.Directory = g.str(KeyBackupDir, s.Backup.Directory)
s.Backup.IntervalHours = g.integer(KeyBackupInterval, s.Backup.IntervalHours)
s.Backup.Retention = g.integer(KeyBackupRetention, s.Backup.Retention)
s.Logging.Level = g.str(KeyLogLevel, s.Logging.Level)
s.Logging.Format = g.str(KeyLogFormat, s.Logging.Format)
s.Logging.AuditMaxRows = g.integer(KeyAuditMaxRows, s.Logging.AuditMaxRows)
s.BlockPage.Enabled = g.boolean(KeyBlockPageEnabled, s.BlockPage.Enabled)
s.BlockPage.HTTPListen = g.str(KeyBlockPageHTTPListen, s.BlockPage.HTTPListen)
s.BlockPage.HTTPSListen = g.str(KeyBlockPageHTTPSListen, s.BlockPage.HTTPSListen)
s.BlockPage.HTML = g.str(KeyBlockPageHTML, s.BlockPage.HTML)
s.Normalise()
return s
}
// ToMap renders the settings back into their stored representation.
func (s Settings) ToMap() map[string]string {
return map[string]string{
KeyDNSUDPListen: s.DNS.UDPListen,
KeyDNSTCPListen: s.DNS.TCPListen,
KeyDNSRecursion: boolStr(s.DNS.Recursion),
KeyDNSEDNSEnabled: boolStr(s.DNS.EDNSEnabled),
KeyDNSEDNSUDPSize: itoa(s.DNS.EDNSUDPSize),
KeyDNSMaxUDPSize: itoa(s.DNS.MaxUDPResponse),
KeyDNSDefaultTTL: itoa(int(s.DNS.DefaultTTL)),
KeyDNSTCPIdle: itoa(s.DNS.TCPIdleSeconds),
KeyDNSExposeVer: boolStr(s.DNS.ExposeVersion),
KeyResolverUpstreams: strings.Join(s.Resolver.Upstreams, "\n"),
KeyResolverTimeout: itoa(s.Resolver.TimeoutMS),
KeyResolverRetries: itoa(s.Resolver.Retries),
KeyResolverStrategy: s.Resolver.Strategy,
KeyResolverAllow: strings.Join(s.Resolver.AllowNetworks, "\n"),
KeyResolverDeny: strings.Join(s.Resolver.DenyNetworks, "\n"),
KeyResolverPreferV6: boolStr(s.Resolver.PreferIPv6),
KeyResolverDNSSEC: boolStr(s.Resolver.DNSSEC),
KeyResolverMaxConc: itoa(s.Resolver.MaxConcurrent),
KeyCacheEnabled: boolStr(s.Cache.Enabled),
KeyCacheMaxEntries: itoa(s.Cache.MaxEntries),
KeyCacheMinTTL: itoa(s.Cache.MinTTL),
KeyCacheMaxTTL: itoa(s.Cache.MaxTTL),
KeyCacheNegativeTTL: itoa(s.Cache.NegativeTTL),
KeyCacheServeStale: boolStr(s.Cache.ServeStale),
KeyCacheStaleTTL: itoa(s.Cache.StaleTTL),
KeyCachePrefetch: boolStr(s.Cache.Prefetch),
KeyCachePrefetchPct: itoa(s.Cache.PrefetchPercent),
KeyCacheCleanup: itoa(s.Cache.CleanupSeconds),
KeyQueryLogEnabled: boolStr(s.QueryLog.Enabled),
KeyQueryLogRetention: itoa(s.QueryLog.RetentionDays),
KeyQueryLogMaxRows: itoa(s.QueryLog.MaxRows),
KeyQueryLogCleanup: itoa(s.QueryLog.CleanupMinutes),
KeyQueryLogIgnoreNet: strings.Join(s.QueryLog.IgnoreNetworks, "\n"),
KeyQueryLogIgnoreDom: strings.Join(s.QueryLog.IgnoreDomains, "\n"),
KeyRateLimitEnabled: boolStr(s.RateLimit.Enabled),
KeyRateLimitQPS: itoa(s.RateLimit.QPS),
KeyRateLimitBurst: itoa(s.RateLimit.Burst),
KeyRateLimitExempt: strings.Join(s.RateLimit.ExemptNetworks, "\n"),
KeyHTTPListen: s.HTTP.Listen,
KeyHTTPBaseURL: s.HTTP.BaseURL,
KeyHTTPTrustedProxy: strings.Join(s.HTTP.TrustedProxies, "\n"),
KeyHTTPMetrics: boolStr(s.HTTP.MetricsEnabled),
KeyHTTPMetricsPublic: boolStr(s.HTTP.MetricsPublic),
KeyHTTPMaxUploadMB: itoa(s.HTTP.MaxUploadMB),
KeyHTTPRateLimit: itoa(s.HTTP.RateLimitPerMin),
KeyBackupEnabled: boolStr(s.Backup.Enabled),
KeyBackupDir: s.Backup.Directory,
KeyBackupInterval: itoa(s.Backup.IntervalHours),
KeyBackupRetention: itoa(s.Backup.Retention),
KeyLogLevel: s.Logging.Level,
KeyLogFormat: s.Logging.Format,
KeyAuditMaxRows: itoa(s.Logging.AuditMaxRows),
KeyBlockPageEnabled: boolStr(s.BlockPage.Enabled),
KeyBlockPageHTTPListen: s.BlockPage.HTTPListen,
KeyBlockPageHTTPSListen: s.BlockPage.HTTPSListen,
KeyBlockPageHTML: s.BlockPage.HTML,
}
}
// Normalise clamps values into sane ranges. It never rejects: it is applied
// after loading so that odd stored values degrade rather than break startup.
func (s *Settings) Normalise() {
s.DNS.EDNSUDPSize = clamp(s.DNS.EDNSUDPSize, 512, 65535)
s.DNS.MaxUDPResponse = clamp(s.DNS.MaxUDPResponse, 512, 65535)
s.DNS.TCPIdleSeconds = clamp(s.DNS.TCPIdleSeconds, 1, 120)
if s.DNS.DefaultTTL == 0 {
s.DNS.DefaultTTL = 3600
}
s.Resolver.TimeoutMS = clamp(s.Resolver.TimeoutMS, 100, 30000)
s.Resolver.Retries = clamp(s.Resolver.Retries, 0, 10)
s.Resolver.MaxConcurrent = clamp(s.Resolver.MaxConcurrent, 1, 10000)
switch s.Resolver.Strategy {
case StrategySequential, StrategyRoundRobin, StrategyRandom, StrategyFastest:
default:
s.Resolver.Strategy = StrategyFastest
}
s.Resolver.Upstreams = normaliseUpstreams(s.Resolver.Upstreams)
s.Cache.MaxEntries = clamp(s.Cache.MaxEntries, 0, 10_000_000)
s.Cache.MinTTL = clamp(s.Cache.MinTTL, 0, 86400)
s.Cache.MaxTTL = clamp(s.Cache.MaxTTL, 1, 604800)
if s.Cache.MinTTL > s.Cache.MaxTTL {
s.Cache.MinTTL = s.Cache.MaxTTL
}
s.Cache.NegativeTTL = clamp(s.Cache.NegativeTTL, 0, 86400)
s.Cache.StaleTTL = clamp(s.Cache.StaleTTL, 0, 604800)
s.Cache.PrefetchPercent = clamp(s.Cache.PrefetchPercent, 1, 90)
s.Cache.CleanupSeconds = clamp(s.Cache.CleanupSeconds, 5, 3600)
s.QueryLog.RetentionDays = clamp(s.QueryLog.RetentionDays, 0, 3650)
s.QueryLog.MaxRows = clamp(s.QueryLog.MaxRows, 0, 100_000_000)
s.QueryLog.CleanupMinutes = clamp(s.QueryLog.CleanupMinutes, 1, 1440)
s.RateLimit.QPS = clamp(s.RateLimit.QPS, 1, 1_000_000)
s.RateLimit.Burst = clamp(s.RateLimit.Burst, 1, 1_000_000)
if s.RateLimit.Burst < s.RateLimit.QPS {
s.RateLimit.Burst = s.RateLimit.QPS
}
s.HTTP.MaxUploadMB = clamp(s.HTTP.MaxUploadMB, 1, 4096)
s.HTTP.RateLimitPerMin = clamp(s.HTTP.RateLimitPerMin, 10, 1_000_000)
s.HTTP.BaseURL = strings.TrimRight(strings.TrimSpace(s.HTTP.BaseURL), "/")
s.Backup.IntervalHours = clamp(s.Backup.IntervalHours, 1, 8760)
s.Backup.Retention = clamp(s.Backup.Retention, 1, 1000)
switch strings.ToLower(s.Logging.Level) {
case "debug", "info", "warn", "error":
s.Logging.Level = strings.ToLower(s.Logging.Level)
default:
s.Logging.Level = "info"
}
switch strings.ToLower(s.Logging.Format) {
case "text", "json":
s.Logging.Format = strings.ToLower(s.Logging.Format)
default:
s.Logging.Format = "text"
}
s.Logging.AuditMaxRows = clamp(s.Logging.AuditMaxRows, 100, 10_000_000)
if strings.TrimSpace(s.BlockPage.HTML) == "" {
s.BlockPage.HTML = DefaultBlockPageHTML
}
}
// Validate reports configuration errors that should be shown to the operator
// rather than silently corrected.
func (s Settings) Validate() error {
if err := validateListenAddr(s.DNS.UDPListen); err != nil {
return fmt.Errorf("DNS UDP listen address: %w", err)
}
if err := validateListenAddr(s.DNS.TCPListen); err != nil {
return fmt.Errorf("DNS TCP listen address: %w", err)
}
if err := validateListenAddr(s.HTTP.Listen); err != nil {
return fmt.Errorf("HTTP listen address: %w", err)
}
if s.DNS.Recursion && len(s.Resolver.Upstreams) == 0 {
return fmt.Errorf("recursion is enabled but no upstream resolvers are configured")
}
for _, u := range s.Resolver.Upstreams {
if err := validateUpstream(u); err != nil {
return fmt.Errorf("upstream resolver %q: %w", u, err)
}
}
if s.DNS.Recursion && len(s.Resolver.AllowNetworks) == 0 {
return fmt.Errorf("recursion is enabled but the allowed-networks list is empty, " +
"which would deny every client; add at least one network")
}
for label, list := range map[string][]string{
"allowed recursion network": s.Resolver.AllowNetworks,
"denied recursion network": s.Resolver.DenyNetworks,
"rate limit exempt network": s.RateLimit.ExemptNetworks,
"query log ignored network": s.QueryLog.IgnoreNetworks,
"trusted proxy": s.HTTP.TrustedProxies,
} {
for _, c := range list {
if _, err := ParseCIDROrIP(c); err != nil {
return fmt.Errorf("%s %q: %w", label, c, err)
}
}
}
if s.HTTP.BaseURL != "" {
if _, err := url.Parse(s.HTTP.BaseURL); err != nil {
return fmt.Errorf("base URL %q is not a valid URL", s.HTTP.BaseURL)
}
}
if s.Backup.Enabled && strings.TrimSpace(s.Backup.Directory) == "" {
return fmt.Errorf("backups are enabled but no backup directory is set")
}
if s.BlockPage.Enabled {
if err := validateListenAddr(s.BlockPage.HTTPListen); err != nil {
return fmt.Errorf("block page HTTP listen address: %w", err)
}
if err := validateListenAddr(s.BlockPage.HTTPSListen); err != nil {
return fmt.Errorf("block page HTTPS listen address: %w", err)
}
if s.BlockPage.HTTPListen == s.BlockPage.HTTPSListen {
return fmt.Errorf("block page HTTP and HTTPS listen addresses must differ")
}
for _, other := range []string{s.HTTP.Listen, s.DNS.UDPListen, s.DNS.TCPListen} {
if s.BlockPage.HTTPListen == other || s.BlockPage.HTTPSListen == other {
return fmt.Errorf("block page listen address %q collides with another listener", other)
}
}
}
return nil
}
// ParseCIDROrIP accepts either a CIDR block or a bare address, returning a
// prefix. A bare address becomes a host route (/32 or /128).
func ParseCIDROrIP(s string) (netip.Prefix, error) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Prefix{}, fmt.Errorf("must not be empty")
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("not a valid CIDR block")
}
return p.Masked(), nil
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("not a valid IP address or CIDR block")
}
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
// normaliseUpstreams trims entries and appends the default port where missing.
func normaliseUpstreams(in []string) []string {
var out []string
seen := map[string]bool{}
for _, u := range in {
u = strings.TrimSpace(u)
if u == "" || strings.HasPrefix(u, "#") {
continue
}
if _, _, err := net.SplitHostPort(u); err != nil {
// Bare IPv6 addresses need brackets before a port can be appended.
if strings.Count(u, ":") >= 2 && !strings.HasPrefix(u, "[") {
u = "[" + u + "]:53"
} else {
u = u + ":53"
}
}
if seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
func validateUpstream(u string) error {
host, port, err := net.SplitHostPort(u)
if err != nil {
return fmt.Errorf("expected host:port")
}
if net.ParseIP(host) == nil {
return fmt.Errorf("host must be a literal IP address, not a name " +
"(resolving upstream names would require the resolver we are configuring)")
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return fmt.Errorf("invalid port %q", port)
}
return nil
}
// --- small helpers ------------------------------------------------------
type getter struct{ m map[string]string }
func (g getter) str(key, def string) string {
if v, ok := g.m[key]; ok {
if t := strings.TrimSpace(v); t != "" {
return t
}
}
return def
}
func (g getter) boolean(key string, def bool) bool {
v, ok := g.m[key]
if !ok {
return def
}
b, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return def
}
return b
}
func (g getter) integer(key string, def int) int {
v, ok := g.m[key]
if !ok {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
// lines splits a multi-line setting, dropping blanks and comments. An empty
// stored value means "explicitly empty" and overrides the default.
func (g getter) lines(key string, def []string) []string {
v, ok := g.m[key]
if !ok {
return def
}
return SplitLines(v)
}
// SplitLines parses a textarea-style list: one entry per line, "#" comments
// and blank lines removed. Commas are also accepted as separators.
func SplitLines(v string) []string {
var out []string
for _, line := range strings.FieldsFunc(v, func(r rune) bool {
return r == '\n' || r == '\r' || r == ','
}) {
line = strings.TrimSpace(line)
if i := strings.Index(line, "#"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
if line == "" {
continue
}
out = append(out, line)
}
return out
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func itoa(i int) string { return strconv.Itoa(i) }
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}