194 lines
5.8 KiB
Go
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
|
|
}
|