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
|
||||
}
|
||||
Reference in New Issue
Block a user