initial commit
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// Package config holds two distinct kinds of configuration.
|
||||
//
|
||||
// Bootstrap holds the startup-critical values that must be known before the
|
||||
// database is open: where the database lives, which addresses to listen on and
|
||||
// the initial administrator. It comes from CLI flags and environment
|
||||
// variables.
|
||||
//
|
||||
// Settings holds everything else. It lives in SQLite, is editable from the web
|
||||
// UI and can mostly be changed without restarting.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bootstrap is the startup configuration.
|
||||
type Bootstrap struct {
|
||||
DBPath string
|
||||
HTTPAddr string
|
||||
DNSUDPAddr string
|
||||
DNSTCPAddr string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
LogLevel string
|
||||
LogFormat string
|
||||
|
||||
// dnsAddrSet records whether --dns was given, so that a stored setting is
|
||||
// only overridden when the operator explicitly asked for it.
|
||||
dnsAddrSet bool
|
||||
httpAddrSet bool
|
||||
}
|
||||
|
||||
// Default values used when neither a flag nor an environment variable is set.
|
||||
const (
|
||||
DefaultDBPath = "./data/dns.db"
|
||||
DefaultHTTPAddr = "127.0.0.1:8080"
|
||||
DefaultDNSAddr = "0.0.0.0:53"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
EnvDBPath = "VIBEDNS_DB_PATH"
|
||||
EnvHTTPAddr = "VIBEDNS_HTTP_ADDR"
|
||||
EnvDNSAddr = "VIBEDNS_DNS_ADDR"
|
||||
EnvAdminUsername = "VIBEDNS_ADMIN_USERNAME"
|
||||
EnvAdminPassword = "VIBEDNS_ADMIN_PASSWORD"
|
||||
EnvLogLevel = "VIBEDNS_LOG_LEVEL"
|
||||
EnvLogFormat = "VIBEDNS_LOG_FORMAT"
|
||||
)
|
||||
|
||||
// DefaultBootstrap returns the built-in defaults with environment overrides
|
||||
// applied.
|
||||
func DefaultBootstrap() Bootstrap {
|
||||
b := Bootstrap{
|
||||
DBPath: envOr(EnvDBPath, DefaultDBPath),
|
||||
HTTPAddr: envOr(EnvHTTPAddr, DefaultHTTPAddr),
|
||||
AdminUsername: envOr(EnvAdminUsername, "admin"),
|
||||
AdminPassword: os.Getenv(EnvAdminPassword),
|
||||
LogLevel: envOr(EnvLogLevel, "info"),
|
||||
LogFormat: envOr(EnvLogFormat, "text"),
|
||||
}
|
||||
dnsAddr := envOr(EnvDNSAddr, DefaultDNSAddr)
|
||||
b.DNSUDPAddr = dnsAddr
|
||||
b.DNSTCPAddr = dnsAddr
|
||||
if _, ok := os.LookupEnv(EnvDNSAddr); ok {
|
||||
b.dnsAddrSet = true
|
||||
}
|
||||
if _, ok := os.LookupEnv(EnvHTTPAddr); ok {
|
||||
b.httpAddrSet = true
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BindFlags registers the bootstrap flags on fs.
|
||||
func (b *Bootstrap) BindFlags(fs *flag.FlagSet) {
|
||||
fs.StringVar(&b.DBPath, "db", b.DBPath, "path to the SQLite database file")
|
||||
fs.StringVar(&b.HTTPAddr, "http", b.HTTPAddr, "management HTTP listen address")
|
||||
fs.StringVar(&b.DNSUDPAddr, "dns", b.DNSUDPAddr, "DNS listen address for both UDP and TCP")
|
||||
fs.StringVar(&b.LogLevel, "log-level", b.LogLevel, "log level: debug, info, warn, error")
|
||||
fs.StringVar(&b.LogFormat, "log-format", b.LogFormat, "log format: text or json")
|
||||
fs.StringVar(&b.AdminUsername, "admin-username", b.AdminUsername,
|
||||
"administrator username created on first run")
|
||||
}
|
||||
|
||||
// NoteFlagsSet records which addressing flags were explicitly provided so that
|
||||
// stored settings are respected otherwise.
|
||||
func (b *Bootstrap) NoteFlagsSet(fs *flag.FlagSet) {
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "dns":
|
||||
b.dnsAddrSet = true
|
||||
b.DNSTCPAddr = b.DNSUDPAddr
|
||||
case "http":
|
||||
b.httpAddrSet = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DNSAddrOverridden reports whether the DNS listen address was given on the
|
||||
// command line or in the environment.
|
||||
func (b Bootstrap) DNSAddrOverridden() bool { return b.dnsAddrSet }
|
||||
|
||||
// HTTPAddrOverridden reports whether the HTTP listen address was overridden.
|
||||
func (b Bootstrap) HTTPAddrOverridden() bool { return b.httpAddrSet }
|
||||
|
||||
// Validate checks the bootstrap configuration and returns an actionable error.
|
||||
func (b Bootstrap) Validate() error {
|
||||
if strings.TrimSpace(b.DBPath) == "" {
|
||||
return errors.New("database path must not be empty")
|
||||
}
|
||||
if !filepath.IsAbs(b.DBPath) {
|
||||
if _, err := filepath.Abs(b.DBPath); err != nil {
|
||||
return fmt.Errorf("database path %q cannot be resolved: %w", b.DBPath, err)
|
||||
}
|
||||
}
|
||||
for label, addr := range map[string]string{
|
||||
"management HTTP address": b.HTTPAddr,
|
||||
"DNS UDP address": b.DNSUDPAddr,
|
||||
"DNS TCP address": b.DNSTCPAddr,
|
||||
} {
|
||||
if err := validateListenAddr(addr); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(b.LogLevel) {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log level %q must be one of debug, info, warn, error", b.LogLevel)
|
||||
}
|
||||
switch strings.ToLower(b.LogFormat) {
|
||||
case "text", "json":
|
||||
default:
|
||||
return fmt.Errorf("log format %q must be text or json", b.LogFormat)
|
||||
}
|
||||
if b.AdminUsername != "" {
|
||||
if err := ValidateUsername(b.AdminUsername); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateListenAddr accepts "host:port" with an optional empty host.
|
||||
func validateListenAddr(addr string) error {
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return errors.New("must not be empty")
|
||||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not a valid host:port address", addr)
|
||||
}
|
||||
p, err := strconv.Atoi(port)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return fmt.Errorf("%q has an invalid port", addr)
|
||||
}
|
||||
if host != "" && net.ParseIP(host) == nil {
|
||||
// Allow host names for the HTTP listener; reject obvious nonsense.
|
||||
if strings.ContainsAny(host, " \t/\\") {
|
||||
return fmt.Errorf("%q has an invalid host", addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUsername enforces a conservative username policy.
|
||||
func ValidateUsername(u string) error {
|
||||
if len(u) < 2 || len(u) > 64 {
|
||||
return errors.New("username must be between 2 and 64 characters")
|
||||
}
|
||||
for _, r := range u {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '.', r == '-', r == '_', r == '@':
|
||||
default:
|
||||
return errors.New("username may contain only letters, digits and the characters . - _ @")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user