// Package config provides application configuration using smartconfig. package config import ( "errors" "fmt" "log/slog" "math" "net/netip" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "git.eeqj.de/sneak/smartconfig" "go.uber.org/fx" "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" keyCacheMaxBytes = "cache_max_bytes" keyBlockedNetworks = "blocked_networks" keyTrustedProxies = "trusted_proxies" ) // placeholderSigningKey is the dummy signing_key shipped in // config.example.yml. It is 45 characters, so it passes the length // check, but it is public in this repository and must be rejected at // startup so no deployment ever signs URLs with it. const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32" // 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") errUnknownEnvVars = errors.New("unknown environment variables") 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") errNotAValidCIDR = errors.New("not a valid CIDR network") 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") errPlaceholderKey = errors.New( "is the placeholder from config.example.yml; " + "generate a real key with: openssl rand -base64 32") errMustBeSetTogether = errors.New("must be set together") errMustNotBeNegative = errors.New("must not be negative") errOverflowsInt64 = errors.New("overflows a 64-bit integer") errNegativeBlockSize = errors.New( "statfs reported negative block size") 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 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 // BlockedNetworks are operator-supplied CIDR ranges to refuse in // addition to the built-in SSRF blocklist. Enforced by the upstream // fetcher's dialer; the built-in ranges always apply. BlockedNetworks []netip.Prefix // TrustedProxies are the CIDR ranges of reverse proxies whose // forwarding headers may be believed. Forwarded headers are honored // only when the immediate peer falls inside one of these ranges; // otherwise the peer address is used and the headers are ignored, so // an untrusted client cannot spoof its address. An omitted key // defaults to the RFC 1918 private ranges (see defaultTrustedProxies), // since pixa is deployed behind a proxy on a private network; an // explicitly empty list trusts nothing and always uses the peer // address, and an explicit list replaces the default. TrustedProxies []netip.Prefix // CacheMaxBytes is the disk cache size limit in bytes. Zero // disables the disk cache entirely. When cache_max_bytes is // omitted from the configuration, this holds the computed default // (75% of free space on the filesystem containing // /cache/, floored at DefaultCacheMaxBytesFloor). CacheMaxBytes int64 // cacheMaxBytesExplicit records whether cache_max_bytes was // explicitly set, in the environment or the configuration file. // Explicit values are used exactly as given; only an omitted key // gets the computed default (and its floor) in resolveCacheMaxBytes. cacheMaxBytesExplicit bool } // New creates a new Config instance from the environment and the // config file. func New(_ fx.Lifecycle, params Params) (*Config, error) { log := params.Logger.Get() err := validateKnownEnvVars() if err != nil { return nil, err } // Look for the config file under the project name (/etc/pixa/, // ~/.config/pixa/), matching the /var/lib/pixa state directory, // not under the daemon name pixad. sc, err := loadConfigFile(log, "pixa") if err != nil { return nil, err } if sc == nil { log.Info("no config file found, using environment variables and defaults") } c, err := newFromSmartConfig(sc) if err != nil { return nil, err } err = c.ensureStateDirWritable() if err != nil { return nil, err } err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe) if err != nil { return nil, err } if c.Debug { params.Logger.EnableDebugLogging() } return c, nil } // newFromSmartConfig constructs a Config from the environment and a // loaded smartconfig instance, and validates it. A nil sc means no // config file was found, in which case every option the environment // does not set 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 } blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks) if err != nil { return nil, err } trustedProxies, err := parseCIDRList(sc, keyTrustedProxies) if err != nil { return nil, err } // parseCIDRList returns a nil slice only when the key is absent; an // explicitly empty list ([]) comes back non-nil and empty. An omitted // key takes the RFC 1918 default, while an explicit empty list is left // as trust-nothing. if trustedProxies == nil { trustedProxies = defaultTrustedProxies() } 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), CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0), BlockedNetworks: blockedNetworks, TrustedProxies: trustedProxies, } // The computed default for cache_max_bytes needs a validated // state_dir, so it is resolved later (resolveCacheMaxBytes); here // we only record whether the operator set the key explicitly. if _, present := lookupValue(sc, keyCacheMaxBytes); present { c.cacheMaxBytesExplicit = true } // 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 _, present := lookupValue(sc, keyDBURL); present { return nil, fmt.Errorf("%s: %w; omit it to derive it from state_dir", settingName(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, keyCacheMaxBytes, keyBlockedNetworks, keyTrustedProxies, "env": return true } return false } // envVarNames returns, for each configuration key, the environment // variable that also sets it: PIXA_ plus the key in upper case, with "." // written as "_", except the port, which REPO_POLICIES.md requires to be // PORT. metrics is set through its two subkeys; env has no variable. func envVarNames() map[string]string { return map[string]string{ //nolint:gosec // G101: variable names, not secrets keyDebug: "PIXA_DEBUG", keyMaintenanceMode: "PIXA_MAINTENANCE_MODE", keyPort: "PORT", keyStateDir: "PIXA_STATE_DIR", keySentryDSN: "PIXA_SENTRY_DSN", keyDBURL: "PIXA_DB_URL", keyMetricsUsername: "PIXA_METRICS_USERNAME", keyMetricsPassword: "PIXA_METRICS_PASSWORD", keySigningKey: "PIXA_SIGNING_KEY", keyAllowlistHosts: "PIXA_ALLOWLIST_HOSTS", keyAllowHTTP: "PIXA_ALLOW_HTTP", keyUpstreamConnectionsPerHost: "PIXA_UPSTREAM_CONNECTIONS_PER_HOST", keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES", keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS", keyTrustedProxies: "PIXA_TRUSTED_PROXIES", } } // validateKnownEnvVars rejects environment variables whose names start // with PIXA_ but that are neither a setting's variable nor // PIXA_CONFIG_PATH, so a misspelled variable fails at startup instead of // being silently ignored, as validateKnownKeys does for config file keys. // New calls it before loading the config file, so the variables the // file's env section sets are not checked, just as validateKnownKeys // leaves that section's contents alone. func validateKnownEnvVars() error { known := map[string]bool{"PIXA_CONFIG_PATH": true} for _, name := range envVarNames() { known[name] = true } var unknown []string for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") switch { case !strings.HasPrefix(name, "PIXA_") || known[name]: continue case name == "PIXA_PORT": unknown = append(unknown, name+" (use PORT for the port)") default: unknown = append(unknown, name) } } if len(unknown) > 0 { sort.Strings(unknown) return fmt.Errorf("%w: %s", errUnknownEnvVars, strings.Join(unknown, ", ")) } return nil } // lookupValue returns the value set for key and whether one is set. The // key's environment variable wins when it is present, even when empty; // its value is a string, read exactly as the same text quoted in the // config file would be. Otherwise the config file's value is used. func lookupValue(sc *smartconfig.Config, key string) (any, bool) { value, present := os.LookupEnv(envVarNames()[key]) if present { return value, true } if sc == nil { return nil, false } return sc.Get(key) } // settingName names key in an error message together with its // environment variable, since either one may have set the value. func settingName(key string) string { return fmt.Sprintf("config key %q (environment variable %s)", key, envVarNames()[key]) } // 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("%s: cannot create directory %q: %w", settingName(keyStateDir), c.StateDir, err) } probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*") if err != nil { return fmt.Errorf("%s: directory %q is not writable: %w", settingName(keyStateDir), c.StateDir, err) } probePath := probe.Name() err = probe.Close() if err != nil { return fmt.Errorf("%s: cannot close probe file %q: %w", settingName(keyStateDir), probePath, err) } err = os.Remove(probePath) if err != nil { return fmt.Errorf("%s: cannot remove probe file %q: %w", settingName(keyStateDir), probePath, err) } return nil } // validateSigningKey checks that the signing key is present, long // enough, and not the public placeholder from config.example.yml. The // key value itself is never echoed in error messages. func (c *Config) validateSigningKey() error { if c.SigningKey == "" { return fmt.Errorf("%s: %w", settingName(keySigningKey), errValueRequired) } // Minimum key length for security (32 bytes = 256 bits) const minKeyLength = 32 if len(c.SigningKey) < minKeyLength { return fmt.Errorf("%s: %w: must be at least %d characters, got %d", settingName(keySigningKey), errValueTooShort, minKeyLength, len(c.SigningKey)) } if c.SigningKey == placeholderSigningKey { return fmt.Errorf("%s: %w", settingName(keySigningKey), errPlaceholderKey) } 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 { err := c.validateSigningKey() if err != nil { return err } const maxPort = 65535 if c.Port < 1 || c.Port > maxPort { return fmt.Errorf("%s: value %d is %w 1-%d", settingName(keyPort), c.Port, errPortOutOfRange, maxPort) } if c.UpstreamConnectionsPerHost < 1 { return fmt.Errorf("%s: value %d %w", settingName(keyUpstreamConnectionsPerHost), c.UpstreamConnectionsPerHost, errTooFewConnections) } if c.StateDir == "" { return fmt.Errorf("%s: %w", settingName(keyStateDir), errValueEmpty) } // Zero is valid (it disables the disk cache); only negative // values are rejected. No floor applies to explicit values. if c.CacheMaxBytes < 0 { return fmt.Errorf("%s: value %d %w", settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative) } 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("%s: value %q is %w", settingName(keySentryDSN), c.SentryDSN, errNotAValidURL) } } if (c.MetricsUsername == "") != (c.MetricsPassword == "") { return fmt.Errorf("%s and %s %w", settingName(keyMetricsUsername), settingName(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("%s: entry %q %w", settingName(keyAllowlistHosts), host, errNotBareHostname) } if strings.Trim(host, ".") == "" { return fmt.Errorf("%s: entry %q %w", settingName(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) int64Val(key string, defaultVal int64) int64 { if l.err != nil { return 0 } val, err := getInt64(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) { raw, ok := lookupValue(sc, 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) { raw, ok := lookupValue(sc, 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("%s: value %q is %w", settingName(key), val, errNotAnInteger) } return parsed, nil default: return 0, fmt.Errorf("config key %q: value %v (%T) is %w", key, raw, raw, errNotAnInteger) } } // getInt64 returns the 64-bit 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 and out-of-range values are never clamped. func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) { raw, ok := lookupValue(sc, key) if !ok { return defaultVal, nil } if raw == nil { return 0, errNullConfigValue(key) } switch val := raw.(type) { case int: return int64(val), nil case int64: return val, nil case uint64: if val > math.MaxInt64 { return 0, fmt.Errorf("config key %q: value %d %w", key, val, errOverflowsInt64) } return int64(val), nil case float64: if val != math.Trunc(val) { return 0, fmt.Errorf("config key %q: value %v is %w", key, val, errNotAnInteger) } return int64(val), nil case string: parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64) if err != nil { return 0, fmt.Errorf("%s: value %q is %w", settingName(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) { raw, ok := lookupValue(sc, 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("%s: value %q is %w", settingName(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 := lookupValue(sc, 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("%s: value %q %w", settingName(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 { val, ok := lookupValue(sc, 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 } // defaultTrustedProxies returns the trusted_proxies default: the three RFC // 1918 private ranges. pixa is always deployed behind a TLS-terminating // reverse proxy, which in practice sits on a private network, so its // forwarding headers are believed unless the operator says otherwise. // Loopback is deliberately excluded: it is not an RFC 1918 range, and no // deployment reaches pixa over it. A fresh slice is returned on each call so // callers may hold it without aliasing shared state. func defaultTrustedProxies() []netip.Prefix { return []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), } } // parseCIDRList parses the value of the named config key into CIDR // prefixes, or returns nil if the key is omitted. It accepts a YAML list // of strings or a comma-separated string. An explicitly null value, a // wrong type, an empty entry, a non-string entry, or an unparseable CIDR // aborts startup naming the key and the offending value; the default // (an empty list) applies only to an omitted key. func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) { raw, ok := lookupValue(sc, key) if !ok { return nil, nil } if raw == nil { return nil, errNullConfigValue(key) } entries, err := cidrListEntries(raw, key) if err != nil { return nil, err } prefixes := make([]netip.Prefix, 0, len(entries)) for _, entry := range entries { prefix, err := netip.ParsePrefix(entry) if err != nil { return nil, fmt.Errorf("%s: value %q is %w", settingName(key), entry, errNotAValidCIDR) } prefixes = append(prefixes, prefix) } return prefixes, nil } // cidrListEntries extracts the raw entries of the named CIDR-list key as // trimmed, non-empty strings, from either a YAML list of strings or a // comma-separated string; an empty string is an empty list, as for // allowlist_hosts. Any other shape is a configuration error. func cidrListEntries(raw any, key string) ([]string, error) { switch val := raw.(type) { case []any: entries := make([]string, 0, len(val)) for _, item := range val { str, ok := item.(string) if !ok { return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w", key, item, item, errNotAString) } if strings.TrimSpace(str) == "" { return nil, fmt.Errorf("config key %q: %w", key, errEmptyListEntry) } entries = append(entries, strings.TrimSpace(str)) } return entries, nil case string: entries := make([]string, 0) if strings.TrimSpace(val) == "" { return entries, nil } for part := range strings.SplitSeq(val, ",") { trimmed := strings.TrimSpace(part) if trimmed == "" { return nil, fmt.Errorf("%s: value %q %w", settingName(key), val, errEmptyEntry) } entries = append(entries, trimmed) } return entries, nil default: return nil, fmt.Errorf("config key %q: value %v (%T) is %w", key, raw, raw, errNotAStringList) } }