Some checks failed
check / check (push) Failing after 42s
A config value that is set but unparseable or invalid now aborts startup with an error naming the offending key and value; defaults apply only to omitted keys. Unknown top-level config keys and unknown metrics subkeys abort startup naming each unknown key, so typos like whitelist_hosts fail immediately instead of being silently ignored. A config file that exists at a standard location but fails to parse is now a fatal error instead of being skipped with a warning. state_dir is verified creatable and writable with a probe file before the listener binds. Port must be in 1-65535 (fractional values are rejected, not truncated), upstream_connections_per_host must be at least 1, allowlist_hosts entries must be bare hostnames, sentry_dsn must be a valid URL when set, and metrics credentials must be set together. The stale signing_key comment in config.example.yml (keyless mode was never implemented) now states the actual requirement. TODO.md records the completed step per its Workflow section.
550 lines
14 KiB
Go
550 lines
14 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. The env section is permitted because
|
|
// smartconfig consumes it for environment variable injection.
|
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
|
var unknown []string
|
|
|
|
for key, value := range sc.Data() {
|
|
if !isKnownConfigKey(key) {
|
|
unknown = append(unknown, 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 := range metricsMap {
|
|
if subkey != "username" && subkey != "password" {
|
|
unknown = append(unknown, "metrics."+subkey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
|
|
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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 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 || raw == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
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 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 || raw == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
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) 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 || raw == nil {
|
|
return defaultVal, nil
|
|
}
|
|
|
|
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: 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 || raw == nil {
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|