// Package config provides application configuration using smartconfig. package config import ( "errors" "fmt" "log/slog" "math" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "git.eeqj.de/sneak/smartconfig" "go.uber.org/fx" "sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/logger" ) // Default configuration values. const ( DefaultPort = 8080 DefaultStateDir = "/var/lib/pixa" DefaultUpstreamConnectionsPerHost = 20 ) // Configuration key names. const ( keyDebug = "debug" keyMaintenanceMode = "maintenance_mode" keyPort = "port" keyStateDir = "state_dir" keySentryDSN = "sentry_dsn" keyDBURL = "db_url" keyMetrics = "metrics" keyMetricsUsername = "metrics.username" keyMetricsPassword = "metrics.password" keySigningKey = "signing_key" keyAllowlistHosts = "allowlist_hosts" keyAllowHTTP = "allow_http" keyUpstreamConnectionsPerHost = "upstream_connections_per_host" ) // Static validation errors. Each use site attaches the offending key // and value by wrapping these with fmt.Errorf and %w. var ( errValueRequired = errors.New("a value is required") errValueEmpty = errors.New("value must not be empty") errUnknownConfigKeys = errors.New("unknown config keys") errNotAString = errors.New("not a string") errNotAnInteger = errors.New("not an integer") errNotABoolean = errors.New("not a boolean") errNotAStringList = errors.New("not a list of strings") errNotAMetricsMap = errors.New("not a map of metrics settings") errEmptyListEntry = errors.New("list contains an empty entry") errEmptyEntry = errors.New("contains an empty entry") errNotAValidURL = errors.New("not a valid URL") errPortOutOfRange = errors.New("outside the valid port range") errTooFewConnections = errors.New("must be at least 1") errValueTooShort = errors.New("value too short") errMustBeSetTogether = errors.New("must be set together") errValueNull = errors.New( "value is null; omit the key entirely to use the default") errValuesNull = errors.New( "value is null; omit a key entirely to use its default") errNotBareHostname = errors.New( "must be a bare hostname without scheme, path, or whitespace") errNoHostnameLabels = errors.New("contains no hostname labels") ) // Params defines dependencies for Config. type Params struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds application configuration values. type Config struct { Debug bool MaintenanceMode bool MetricsPassword string MetricsUsername string Port int SentryDSN string StateDir string DBURL string // Image proxy settings SigningKey string // HMAC signing key for URL signatures AllowlistHosts []string // Hosts that don't require signatures AllowHTTP bool // Allow non-TLS upstream (testing only) UpstreamConnectionsPerHost int // Max concurrent connections per upstream host } // New creates a new Config instance by loading configuration from file. func New(_ fx.Lifecycle, params Params) (*Config, error) { log := params.Logger.Get() name := params.Globals.Appname sc, err := loadConfigFile(log, name) if err != nil { return nil, err } if sc == nil { log.Info("no config file found, using defaults") } c, err := newFromSmartConfig(sc) if err != nil { return nil, err } err = c.ensureStateDirWritable() if err != nil { return nil, err } if c.Debug { params.Logger.EnableDebugLogging() } return c, nil } // newFromSmartConfig constructs a Config from a loaded smartconfig // instance and validates it. A nil sc means no config file was found, // in which case every option takes its default value. A key that is // present but unparseable or invalid is an error: defaults apply only // to omitted keys, never to invalid explicit values. func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { if sc != nil { err := validateKnownKeys(sc) if err != nil { return nil, err } err = validateAllowlistHostsValue(sc) if err != nil { return nil, err } } loader := &strictLoader{sc: sc} c := &Config{ Debug: loader.boolVal(keyDebug, false), MaintenanceMode: loader.boolVal(keyMaintenanceMode, false), Port: loader.intVal(keyPort, DefaultPort), StateDir: loader.stringVal(keyStateDir, DefaultStateDir), SentryDSN: loader.stringVal(keySentryDSN, ""), MetricsUsername: loader.stringVal(keyMetricsUsername, ""), MetricsPassword: loader.stringVal(keyMetricsPassword, ""), SigningKey: loader.stringVal(keySigningKey, ""), AllowlistHosts: getStringSlice(sc), AllowHTTP: loader.boolVal(keyAllowHTTP, false), UpstreamConnectionsPerHost: loader.intVal( keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost), } // Build DBURL from StateDir if not explicitly set. The derived URL // is a default: it applies only when db_url is omitted, never to an // explicitly empty value. c.DBURL = loader.stringVal(keyDBURL, "") if c.DBURL == "" && loader.err == nil { if sc != nil { if _, present := sc.Get(keyDBURL); present { return nil, fmt.Errorf( "config key %q: %w; omit the key to derive it from state_dir", keyDBURL, errValueEmpty) } } c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir) } if loader.err != nil { return nil, loader.err } err := c.validate() if err != nil { return nil, err } return c, nil } // validateKnownKeys rejects configuration files containing keys the // application does not understand, so typos fail at startup instead of // being silently ignored, and rejects keys that are explicitly set to // null: a null is a SET value, never an omission, so it must not // silently take the default. The env section is permitted because // smartconfig consumes it for environment variable injection. func validateKnownKeys(sc *smartconfig.Config) error { var unknown, nullKeys []string for key, value := range sc.Data() { if !isKnownConfigKey(key) { unknown = append(unknown, key) continue } if value == nil { nullKeys = append(nullKeys, key) continue } if key == keyMetrics { metricsMap, ok := value.(map[string]any) if !ok { return fmt.Errorf("config key %q: value %v is %w", keyMetrics, value, errNotAMetricsMap) } for subkey, subvalue := range metricsMap { if subkey != "username" && subkey != "password" { unknown = append(unknown, keyMetrics+"."+subkey) continue } if subvalue == nil { nullKeys = append(nullKeys, keyMetrics+"."+subkey) } } } } if len(unknown) > 0 { sort.Strings(unknown) return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", ")) } if len(nullKeys) > 0 { sort.Strings(nullKeys) if len(nullKeys) == 1 { return errNullConfigValue(nullKeys[0]) } return fmt.Errorf("config keys %s: %w", strings.Join(nullKeys, ", "), errValuesNull) } return nil } // errNullConfigValue reports a config key that is explicitly set to // null (including the bare "key:" form and the "~" alias). Silently // applying the default would mask a truncated or typo'd config entry. func errNullConfigValue(key string) error { return fmt.Errorf("config key %q: %w", key, errValueNull) } // isKnownConfigKey reports whether key is a permitted top-level // configuration key. func isKnownConfigKey(key string) bool { switch key { case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN, keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP, keyUpstreamConnectionsPerHost, "env": return true } return false } // ensureStateDirWritable verifies at startup that StateDir can be // created and written to, so a misconfigured path aborts startup // instead of failing later at first use. func (c *Config) ensureStateDirWritable() error { const stateDirPerms = 0o750 err := os.MkdirAll(c.StateDir, stateDirPerms) if err != nil { return fmt.Errorf("config key %q: cannot create directory %q: %w", keyStateDir, c.StateDir, err) } probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*") if err != nil { return fmt.Errorf("config key %q: directory %q is not writable: %w", keyStateDir, c.StateDir, err) } probePath := probe.Name() err = probe.Close() if err != nil { return fmt.Errorf("config key %q: cannot close probe file %q: %w", keyStateDir, probePath, err) } err = os.Remove(probePath) if err != nil { return fmt.Errorf("config key %q: cannot remove probe file %q: %w", keyStateDir, probePath, err) } return nil } // validate checks that all required configuration values are set and // that every value is within its valid range. func (c *Config) validate() error { // The signing key value is never echoed in error messages. if c.SigningKey == "" { return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired) } // Minimum key length for security (32 bytes = 256 bits) const minKeyLength = 32 if len(c.SigningKey) < minKeyLength { return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d", keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey)) } const maxPort = 65535 if c.Port < 1 || c.Port > maxPort { return fmt.Errorf("config key %q: value %d is %w 1-%d", keyPort, c.Port, errPortOutOfRange, maxPort) } if c.UpstreamConnectionsPerHost < 1 { return fmt.Errorf("config key %q: value %d %w", keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost, errTooFewConnections) } if c.StateDir == "" { return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty) } for _, host := range c.AllowlistHosts { err := validateAllowlistHost(host) if err != nil { return err } } if c.SentryDSN != "" { parsed, err := url.Parse(c.SentryDSN) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("config key %q: value %q is %w", keySentryDSN, c.SentryDSN, errNotAValidURL) } } if (c.MetricsUsername == "") != (c.MetricsPassword == "") { return fmt.Errorf("config keys %q and %q %w", keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether) } return nil } // validateAllowlistHost checks that an allowlist_hosts entry is a bare // hostname, optionally with a leading dot for suffix matching. URLs, // paths, and whitespace indicate a misconfigured entry. An entry with // no hostname labels (such as ".") is rejected: the allowlist matcher // treats a leading dot as a suffix pattern, so a bare "." would match // any upstream host written in FQDN trailing-dot form and effectively // disable URL signing. func validateAllowlistHost(host string) error { if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") { return fmt.Errorf("config key %q: entry %q %w", keyAllowlistHosts, host, errNotBareHostname) } if strings.Trim(host, ".") == "" { return fmt.Errorf("config key %q: entry %q %w", keyAllowlistHosts, host, errNoHostnameLabels) } return nil } // loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var // or standard locations. func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) { // Check for explicit config path from environment if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" { sc, err := smartconfig.NewFromConfigPath(envPath) if err != nil { return nil, fmt.Errorf("failed to load config from %s: %w", envPath, err) } log.Info("loaded config file", "path", envPath) return sc, nil } // Try loading config from standard locations configPaths := []string{ fmt.Sprintf("/etc/%s/config.yml", appName), fmt.Sprintf("/etc/%s/config.yaml", appName), filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yml"), filepath.Join(os.Getenv("HOME"), ".config", appName, "config.yaml"), "config.yml", "config.yaml", } for _, path := range configPaths { cleanPath := filepath.Clean(path) _, statErr := os.Stat(cleanPath) if statErr == nil { // A config file that exists but does not parse is a fatal // startup error, never something to skip over. sc, err := smartconfig.NewFromConfigPath(path) if err != nil { return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) } log.Info("loaded config file", "path", path) return sc, nil } } return nil, nil //nolint:nilnil // nil config is valid (use defaults) } // strictLoader accumulates the first error encountered while reading // typed values out of a smartconfig instance, so Config construction // can stay a single struct literal. type strictLoader struct { sc *smartconfig.Config err error } func (l *strictLoader) stringVal(key, defaultVal string) string { if l.err != nil { return "" } val, err := getString(l.sc, key, defaultVal) if err != nil { l.err = err } return val } func (l *strictLoader) intVal(key string, defaultVal int) int { if l.err != nil { return 0 } val, err := getInt(l.sc, key, defaultVal) if err != nil { l.err = err } return val } func (l *strictLoader) boolVal(key string, defaultVal bool) bool { if l.err != nil { return false } val, err := getBool(l.sc, key, defaultVal) if err != nil { l.err = err } return val } // getString returns the string value for key, or defaultVal if the key // is omitted. A present value that is not a string, or is explicitly // null, is an error. func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) if !ok { return defaultVal, nil } if raw == nil { return "", errNullConfigValue(key) } str, ok := raw.(string) if !ok { return "", fmt.Errorf("config key %q: value %v (%T) is %w", key, raw, raw, errNotAString) } return str, nil } // getInt returns the integer value for key, or defaultVal if the key is // omitted. A present value that is not a whole number, or is explicitly // null, is an error; fractional values are never truncated. func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) if !ok { return defaultVal, nil } if raw == nil { return 0, errNullConfigValue(key) } switch val := raw.(type) { case int: return val, nil case int64: return int(val), nil case float64: if val != math.Trunc(val) { return 0, fmt.Errorf("config key %q: value %v is %w", key, val, errNotAnInteger) } return int(val), nil case string: parsed, err := strconv.Atoi(strings.TrimSpace(val)) if err != nil { return 0, fmt.Errorf("config key %q: value %q is %w", key, val, errNotAnInteger) } return parsed, nil default: return 0, fmt.Errorf("config key %q: value %v (%T) is %w", key, raw, raw, errNotAnInteger) } } // getBool returns the boolean value for key, or defaultVal if the key // is omitted. A present value that is not a boolean (or a ParseBool-able // string), or is explicitly null, is an error; numbers are not accepted // as booleans. func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) if !ok { return defaultVal, nil } if raw == nil { return false, errNullConfigValue(key) } switch val := raw.(type) { case bool: return val, nil case string: parsed, err := strconv.ParseBool(strings.TrimSpace(val)) if err != nil { return false, fmt.Errorf("config key %q: value %q is %w", key, val, errNotABoolean) } return parsed, nil default: return false, fmt.Errorf("config key %q: value %v (%T) is %w", key, raw, raw, errNotABoolean) } } // validateAllowlistHostsValue checks the raw shape of the // allowlist_hosts value before the lenient extraction in getStringSlice // runs: an explicitly null value, a value that is not a list of strings // (or a comma-separated string), a non-string entry, or an empty entry // is an error, never silently skipped. func validateAllowlistHostsValue(sc *smartconfig.Config) error { raw, ok := sc.Get(keyAllowlistHosts) if !ok { return nil } if raw == nil { return errNullConfigValue(keyAllowlistHosts) } switch val := raw.(type) { case []any: for _, item := range val { str, ok := item.(string) if !ok { return fmt.Errorf("config key %q: list entry %v (%T) is %w", keyAllowlistHosts, item, item, errNotAString) } if strings.TrimSpace(str) == "" { return fmt.Errorf("config key %q: %w", keyAllowlistHosts, errEmptyListEntry) } } case string: if strings.TrimSpace(val) == "" { return nil } for part := range strings.SplitSeq(val, ",") { if strings.TrimSpace(part) == "" { return fmt.Errorf("config key %q: value %q %w", keyAllowlistHosts, val, errEmptyEntry) } } default: return fmt.Errorf("config key %q: value %v (%T) is %w", keyAllowlistHosts, raw, raw, errNotAStringList) } return nil } // getStringSlice returns the allowlist_hosts list of strings, or nil if // the key is omitted. It accepts a YAML list of strings or a // comma-separated string (backwards compatibility). Malformed entries // are rejected beforehand by validateAllowlistHostsValue. func getStringSlice(sc *smartconfig.Config) []string { if sc == nil { return nil } val, ok := sc.Get(keyAllowlistHosts) if !ok || val == nil { return nil } // Handle YAML list format if slice, ok := val.([]any); ok { result := make([]string, 0, len(slice)) for _, item := range slice { if str, ok := item.(string); ok { trimmed := strings.TrimSpace(str) if trimmed != "" { result = append(result, trimmed) } } } return result } // Fall back to comma-separated string for backwards compatibility if str, ok := val.(string); ok && str != "" { parts := strings.Split(str, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result } return nil }