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