initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+193
View File
@@ -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
}
+661
View File
@@ -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
}