An explicitly-null key (port: null, bare port:, debug: ~, metrics subkeys, and every other known key) previously fell through the ok/nil check in the strict getters and silently took the default, violating the no-silent-fallback rule and contradicting metrics: null which already aborted. validateKnownKeys now collects null-valued keys (top level and metrics subkeys) and aborts naming each one, and the strict getters and validateAllowlistHostsValue error on null instead of defaulting as defense in depth. This also replaces the unhelpful 'value <nil> is not a map of metrics settings' rendering for metrics: null with the null-specific message.
603 lines
15 KiB
Go
603 lines
15 KiB
Go
// Package config provides application configuration using smartconfig.
|
|
package config
|
|
|
|
import (
|
|
"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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
if err := c.ensureStateDirWritable(); 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 {
|
|
if err := validateKnownKeys(sc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := validateAllowlistHostsValue(sc); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
loader := &strictLoader{sc: sc}
|
|
|
|
c := &Config{
|
|
Debug: loader.boolVal("debug", false),
|
|
MaintenanceMode: loader.boolVal("maintenance_mode", false),
|
|
Port: loader.intVal("port", DefaultPort),
|
|
StateDir: loader.stringVal("state_dir", DefaultStateDir),
|
|
SentryDSN: loader.stringVal("sentry_dsn", ""),
|
|
MetricsUsername: loader.stringVal("metrics.username", ""),
|
|
MetricsPassword: loader.stringVal("metrics.password", ""),
|
|
SigningKey: loader.stringVal("signing_key", ""),
|
|
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
|
|
AllowHTTP: loader.boolVal("allow_http", false),
|
|
UpstreamConnectionsPerHost: loader.intVal(
|
|
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
|
}
|
|
|
|
// Build DBURL from StateDir if not explicitly set
|
|
c.DBURL = loader.stringVal("db_url", "")
|
|
if c.DBURL == "" {
|
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
|
}
|
|
|
|
if loader.err != nil {
|
|
return nil, loader.err
|
|
}
|
|
|
|
if err := c.validate(); 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 == "metrics" {
|
|
metricsMap, ok := value.(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"config key %q: value %v is not a map of metrics settings",
|
|
"metrics", value)
|
|
}
|
|
|
|
for subkey, subvalue := range metricsMap {
|
|
if subkey != "username" && subkey != "password" {
|
|
unknown = append(unknown, "metrics."+subkey)
|
|
|
|
continue
|
|
}
|
|
|
|
if subvalue == nil {
|
|
nullKeys = append(nullKeys, "metrics."+subkey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
|
|
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
|
}
|
|
|
|
if len(nullKeys) > 0 {
|
|
sort.Strings(nullKeys)
|
|
|
|
if len(nullKeys) == 1 {
|
|
return errNullConfigValue(nullKeys[0])
|
|
}
|
|
|
|
return fmt.Errorf(
|
|
"config keys %s: value is null; omit a key entirely to use its default",
|
|
strings.Join(nullKeys, ", "))
|
|
}
|
|
|
|
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: value is null; omit the key entirely to use the default", key)
|
|
}
|
|
|
|
// isKnownConfigKey reports whether key is a permitted top-level
|
|
// configuration key.
|
|
func isKnownConfigKey(key string) bool {
|
|
switch key {
|
|
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
|
|
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
|
|
"upstream_connections_per_host", "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
|
|
|
|
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil {
|
|
return fmt.Errorf("config key %q: cannot create directory %q: %w",
|
|
"state_dir", 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",
|
|
"state_dir", c.StateDir, err)
|
|
}
|
|
|
|
probePath := probe.Name()
|
|
|
|
if err := probe.Close(); err != nil {
|
|
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
|
|
"state_dir", probePath, err)
|
|
}
|
|
|
|
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
|
|
if err := os.Remove(probePath); err != nil {
|
|
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
|
"state_dir", 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 {
|
|
if c.SigningKey == "" {
|
|
return fmt.Errorf("signing_key is required")
|
|
}
|
|
|
|
// Minimum key length for security (32 bytes = 256 bits)
|
|
const minKeyLength = 32
|
|
if len(c.SigningKey) < minKeyLength {
|
|
return fmt.Errorf("signing_key must be at least %d characters, got %d",
|
|
minKeyLength, len(c.SigningKey))
|
|
}
|
|
|
|
const maxPort = 65535
|
|
if c.Port < 1 || c.Port > maxPort {
|
|
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d",
|
|
"port", c.Port, maxPort)
|
|
}
|
|
|
|
if c.UpstreamConnectionsPerHost < 1 {
|
|
return fmt.Errorf("config key %q: value %d must be at least 1",
|
|
"upstream_connections_per_host", c.UpstreamConnectionsPerHost)
|
|
}
|
|
|
|
if c.StateDir == "" {
|
|
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
|
|
}
|
|
|
|
for _, host := range c.AllowlistHosts {
|
|
if err := validateAllowlistHost(host); 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 not a valid URL",
|
|
"sentry_dsn", c.SentryDSN)
|
|
}
|
|
}
|
|
|
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
|
return fmt.Errorf("config keys %q and %q must be set together",
|
|
"metrics.username", "metrics.password")
|
|
}
|
|
|
|
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.
|
|
func validateAllowlistHost(host string) error {
|
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
|
return fmt.Errorf(
|
|
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace",
|
|
"allowlist_hosts", host)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadConfigFile loads configuration from 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)
|
|
//nolint:gosec // G703: paths are hardcoded config locations
|
|
if _, statErr := os.Stat(cleanPath); 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 not a string",
|
|
key, raw, raw)
|
|
}
|
|
|
|
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 not an integer", key, val)
|
|
}
|
|
|
|
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 not an integer", key, val)
|
|
}
|
|
|
|
return parsed, nil
|
|
default:
|
|
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
|
key, raw, raw)
|
|
}
|
|
}
|
|
|
|
// 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 not a boolean", key, val)
|
|
}
|
|
|
|
return parsed, nil
|
|
default:
|
|
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean",
|
|
key, raw, raw)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
const key = "allowlist_hosts"
|
|
|
|
raw, ok := sc.Get(key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
if raw == nil {
|
|
return errNullConfigValue(key)
|
|
}
|
|
|
|
switch val := raw.(type) {
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"config key %q: list entry %v (%T) is not a string", key, item, item)
|
|
}
|
|
|
|
if strings.TrimSpace(str) == "" {
|
|
return fmt.Errorf("config key %q: list contains an empty entry", key)
|
|
}
|
|
}
|
|
case string:
|
|
if strings.TrimSpace(val) == "" {
|
|
return nil
|
|
}
|
|
|
|
for _, part := range strings.Split(val, ",") {
|
|
if strings.TrimSpace(part) == "" {
|
|
return fmt.Errorf(
|
|
"config key %q: value %q contains an empty entry", key, val)
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings",
|
|
key, raw, raw)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getStringSlice returns the list of strings for key, 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, key string) []string {
|
|
if sc == nil {
|
|
return nil
|
|
}
|
|
|
|
val, ok := sc.Get(key)
|
|
if !ok || val == nil {
|
|
return nil
|
|
}
|
|
|
|
// Handle YAML list format
|
|
if slice, ok := val.([]interface{}); 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
|
|
}
|