Merge branch 'main' into golangci-v2.12.2
Resolves conflicts with the startup config validation from #53: internal/config/config.go takes main's validation implementation wholesale, with getStringSlice mechanically adapted to this branch's keyless signature; TODO.md keeps both Completed Steps entries.
This commit is contained in:
@@ -2,11 +2,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
@@ -25,17 +28,10 @@ const (
|
||||
// Params defines dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Static validation errors.
|
||||
var (
|
||||
errSigningKeyRequired = errors.New("signing_key is required")
|
||||
errSigningKeyTooShort = errors.New("signing_key too short")
|
||||
)
|
||||
|
||||
// Config holds application configuration values.
|
||||
type Config struct {
|
||||
Debug bool
|
||||
@@ -68,60 +64,277 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||
log.Info("no config file found, using defaults")
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
Debug: getBool(sc, "debug", false),
|
||||
MaintenanceMode: getBool(sc, "maintenance_mode", false),
|
||||
Port: getInt(sc, "port", DefaultPort),
|
||||
StateDir: getString(sc, "state_dir", DefaultStateDir),
|
||||
SentryDSN: getString(sc, "sentry_dsn", ""),
|
||||
MetricsUsername: getString(sc, "metrics.username", ""),
|
||||
MetricsPassword: getString(sc, "metrics.password", ""),
|
||||
SigningKey: getString(sc, "signing_key", ""),
|
||||
AllowlistHosts: getStringSlice(sc),
|
||||
AllowHTTP: getBool(sc, "allow_http", false),
|
||||
UpstreamConnectionsPerHost: getInt(
|
||||
sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost,
|
||||
),
|
||||
c, err := newFromSmartConfig(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build DBURL from StateDir if not explicitly set
|
||||
c.DBURL = getString(sc, "db_url", "")
|
||||
if c.DBURL == "" {
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||
if err := c.ensureStateDirWritable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
}
|
||||
|
||||
// Validate required configuration
|
||||
err = c.validate()
|
||||
if err != nil {
|
||||
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),
|
||||
AllowHTTP: loader.boolVal("allow_http", false),
|
||||
UpstreamConnectionsPerHost: loader.intVal(
|
||||
"upstream_connections_per_host", 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("db_url", "")
|
||||
if c.DBURL == "" && loader.err == nil {
|
||||
if sc != nil {
|
||||
if _, present := sc.Get("db_url"); present {
|
||||
return nil, fmt.Errorf(
|
||||
"config key %q: value must not be empty; omit the key to derive it from state_dir",
|
||||
"db_url")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// validate checks that all required configuration values are set.
|
||||
func (c *Config) validate() error {
|
||||
if c.SigningKey == "" {
|
||||
return errSigningKeyRequired
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum key length for security (32 bytes = 256 bits)
|
||||
const minKeyLength = 32
|
||||
if len(c.SigningKey) < minKeyLength {
|
||||
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(
|
||||
"%w: must be at least %d characters", errSigningKeyTooShort, minKeyLength,
|
||||
)
|
||||
"config keys %s: value is null; omit a key entirely to use its default",
|
||||
strings.Join(nullKeys, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
||||
// or standard locations.
|
||||
// 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 {
|
||||
// The signing key value is never echoed in error messages.
|
||||
if c.SigningKey == "" {
|
||||
return fmt.Errorf("config key %q: a value is required", "signing_key")
|
||||
}
|
||||
|
||||
// Minimum key length for security (32 bytes = 256 bits)
|
||||
const minKeyLength = 32
|
||||
if len(c.SigningKey) < minKeyLength {
|
||||
return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
|
||||
"signing_key", 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. 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 must be a bare hostname without scheme, path, or whitespace",
|
||||
"allowlist_hosts", host)
|
||||
}
|
||||
|
||||
if strings.Trim(host, ".") == "" {
|
||||
return fmt.Errorf(
|
||||
"config key %q: entry %q contains no hostname labels",
|
||||
"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 != "" {
|
||||
@@ -147,14 +360,13 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
||||
|
||||
for _, path := range configPaths {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
_, statErr := os.Stat(cleanPath)
|
||||
if statErr == nil {
|
||||
//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 {
|
||||
log.Warn("failed to parse config file", "path", path, "error", err)
|
||||
|
||||
continue
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
log.Info("loaded config file", "path", path)
|
||||
@@ -166,45 +378,207 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||
}
|
||||
|
||||
func getString(sc *smartconfig.Config, key, defaultVal string) string {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
// 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 := sc.GetString(key)
|
||||
val, err := getString(l.sc, key, defaultVal)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
l.err = err
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func getInt(sc *smartconfig.Config, key string, defaultVal int) int {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
func (l *strictLoader) intVal(key string, defaultVal int) int {
|
||||
if l.err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
val, err := sc.GetInt(key)
|
||||
val, err := getInt(l.sc, key, defaultVal)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
l.err = err
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool {
|
||||
if sc == nil {
|
||||
return defaultVal
|
||||
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||
if l.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
val, err := sc.GetBool(key)
|
||||
val, err := getBool(l.sc, key, defaultVal)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
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 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
|
||||
@@ -216,7 +590,7 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
||||
}
|
||||
|
||||
// Handle YAML list format
|
||||
if slice, ok := val.([]any); ok {
|
||||
if slice, ok := val.([]interface{}); ok {
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, item := range slice {
|
||||
if str, ok := item.(string); ok {
|
||||
|
||||
Reference in New Issue
Block a user