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
713 lines
24 KiB
Go
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
|
|
}
|