Files
2026-08-16 21:18:45 -05:00

194 lines
5.8 KiB
Go

// 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
}