// Package config provides application configuration using smartconfig. package config import ( "errors" "fmt" "io/fs" "log/slog" "math" "net/netip" "net/url" "os" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "syscall" "time" "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 DefaultUpstreamConnections = 64 DefaultAccessControlAllowOrigin = "*" DefaultUpstreamFetchTimeout = 30 * time.Second DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB DefaultDownstreamTimeout = 60 * time.Second DefaultImageRequestsPerMinute = 600 ) // DefaultMaxConcurrentProcessing returns the most images processed at once // when max_concurrent_processing is omitted: half the CPUs Go uses // (runtime.GOMAXPROCS(0), which follows a container's CPU limit), rounded // down, and at least 1. func DefaultMaxConcurrentProcessing() int { return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs } // DefaultCacheMaxBytesFloor is the least DefaultCacheMaxBytes returns: // 500 MiB. It applies only to the default, never to an explicit // cache_max_bytes. const DefaultCacheMaxBytesFloor int64 = 500 << 20 // freeSpaceFractionNumerator and freeSpaceFractionDenominator express // the 75% share of DefaultCacheMaxBytes as integer arithmetic (dividing // before multiplying avoids overflow). const ( freeSpaceFractionNumerator uint64 = 3 freeSpaceFractionDenominator uint64 = 4 ) // DefaultCacheMaxBytes returns the disk cache limit when cache_max_bytes // is omitted: 75% of the sum of freeBytes, the space free on the // filesystem holding the cache, and heldBytes, the bytes the cache // already holds, and at least DefaultCacheMaxBytesFloor. Counting what // the cache holds keeps the limit from shrinking as the cache fills. // pixad works it out when it builds the cache, as both numbers are known // only then. func DefaultCacheMaxBytes(freeBytes uint64, heldBytes int64) int64 { // Both terms are at most math.MaxInt64, so the sum cannot overflow. //nolint:gosec // G115: heldBytes is the cache's total, never negative spaceBytes := min(freeBytes, math.MaxInt64) + uint64(heldBytes) computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator computed = min(computed, math.MaxInt64) return max(int64(computed), DefaultCacheMaxBytesFloor) } // placeholderSigningKey is the dummy signing_key shipped in // configs/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") errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96") errNotAMap = errors.New("not a map") 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") errSizeOutOfRange = errors.New("outside the accepted range") errMustBeAtLeastOne = errors.New("must be at least 1") errValueTooShort = errors.New("value too short") errPlaceholderKey = errors.New( "is the placeholder from configs/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") 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") errNotAHost = errors.New("must be a host name such as " + "cdn.example.com or .example.com, or an IP address") errNotADuration = errors.New("not a duration such as 30s or 2m") errMustBePositive = errors.New("must be positive") errNotAnOrigin = errors.New( `not "*" or an origin such as https://example.com`) ) // 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 // RefererBlocklist holds host patterns, matched as AllowlistHosts is: the // image routes refuse a request whose Referer names a matching host. RefererBlocklist []string // UpstreamConnections is the most concurrent connections to all // upstream hosts together, on top of the per-host limit. // MaxConcurrentProcessing is the most images processed at once. UpstreamConnections int MaxConcurrentProcessing int // UpstreamFetchTimeout is the time allowed for one fetch from an // upstream host. UpstreamMaxResponseSize is the largest upstream // response accepted, in bytes, and also the image processor's input // limit. UpstreamFetchTimeout time.Duration UpstreamMaxResponseSize int64 // AccessControlAllowOrigin is the origin the CORS middleware allows // to read responses: "*" for any, or one origin such as // https://example.com. AccessControlAllowOrigin string // DownstreamTimeout bounds how long answering one client request may // take. It is both the HTTP server's write timeout and the deadline // of the per-request timeout middleware. DownstreamTimeout time.Duration // ImageRequestsPerMinute is how many requests one client may make to // the image routes per minute; the next is refused with 429 Too Many // Requests. 0 turns the limit off. ImageRequestsPerMinute int // 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, as set; 0 // turns the disk cache off. 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. For an omitted key // pixad works out DefaultCacheMaxBytes when it builds the cache. 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() // 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 } // Loading the config file sets the variables in its env section, // so this also checks their names. err = validateKnownEnvVars() 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 } // An omitted cache_max_bytes is worked out and logged when pixad // builds the cache. if c.CacheMaxBytesExplicit { log.Info("effective cache size limit", "cache_max_bytes", c.CacheMaxBytes, "cache_disabled", c.CacheMaxBytes == 0, ) } 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 setting 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) { err := validateKnownKeys(sc) if err != nil { return nil, err } c := &Config{} for _, s := range c.settings() { err := s.load(lookupValue(sc, s)) if err != nil { return nil, err } } // The metrics username and password are checked together, once both // have loaded. if (c.MetricsUsername == "") != (c.MetricsPassword == "") { return nil, fmt.Errorf("%s and %s %w", settingName("metrics.username"), settingName("metrics.password"), errMustBeSetTogether) } return c, nil } // A setting is one row of the settings table. key is its key in the // config file, and envVar is the environment variable that also sets it. // load reads the value set for it into its Config field. type setting struct { key string envVar string load func(v value) error } // A value is what the environment or the config file sets for one // setting. present is false when neither sets it. type value struct { key string raw any present bool } // settings returns the settings table: every setting pixa reads, once // each, in the order they load into c. Most rows fill their Config field // with field, whose arguments give the setting's type, its default and // its check. Code that needs only the keys and the variables reads the // table of a new Config. func (c *Config) settings() []setting { return []setting{ {"signing_key", "PIXA_SIGNING_KEY", field(&c.SigningKey, getString, "", validateSigningKey)}, {"port", "PORT", field(&c.Port, getInt, DefaultPort, validatePort)}, {"state_dir", "PIXA_STATE_DIR", field(&c.StateDir, getString, DefaultStateDir, validateNotEmpty)}, {"db_url", "PIXA_DB_URL", c.loadDBURL}, {"cache_max_bytes", "PIXA_CACHE_MAX_BYTES", c.loadCacheMaxBytes}, {"allowlist_hosts", "PIXA_ALLOWLIST_HOSTS", field(&c.AllowlistHosts, parseHostList, nil, nil)}, {"referer_blocklist", "PIXA_REFERER_BLOCKLIST", field(&c.RefererBlocklist, parseHostList, nil, nil)}, {"blocked_networks", "PIXA_BLOCKED_NETWORKS", field(&c.BlockedNetworks, parseCIDRList, nil, nil)}, {"trusted_proxies", "PIXA_TRUSTED_PROXIES", field(&c.TrustedProxies, parseCIDRList, defaultTrustedProxies(), nil)}, {"allow_http", "PIXA_ALLOW_HTTP", field(&c.AllowHTTP, getBool, false, nil)}, {"upstream_connections_per_host", "PIXA_UPSTREAM_CONNECTIONS_PER_HOST", field(&c.UpstreamConnectionsPerHost, getInt, DefaultUpstreamConnectionsPerHost, validateAtLeastOne)}, {"upstream_connections", "PIXA_UPSTREAM_CONNECTIONS", field(&c.UpstreamConnections, getInt, DefaultUpstreamConnections, validateAtLeastOne)}, {"max_concurrent_processing", "PIXA_MAX_CONCURRENT_PROCESSING", field(&c.MaxConcurrentProcessing, getInt, DefaultMaxConcurrentProcessing(), validateAtLeastOne)}, {"image_requests_per_minute", "PIXA_IMAGE_REQUESTS_PER_MINUTE", field(&c.ImageRequestsPerMinute, getInt, DefaultImageRequestsPerMinute, validateNotNegative)}, {"upstream_fetch_timeout", "PIXA_UPSTREAM_FETCH_TIMEOUT", field(&c.UpstreamFetchTimeout, getDuration, DefaultUpstreamFetchTimeout, nil)}, {"upstream_max_response_size", "PIXA_UPSTREAM_MAX_RESPONSE_SIZE", field(&c.UpstreamMaxResponseSize, getInt64, DefaultUpstreamMaxResponseSize, validateUpstreamMaxResponseSize)}, {"downstream_timeout", "PIXA_DOWNSTREAM_TIMEOUT", field(&c.DownstreamTimeout, getDuration, DefaultDownstreamTimeout, nil)}, {"access_control_allow_origin", "PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", field(&c.AccessControlAllowOrigin, getString, DefaultAccessControlAllowOrigin, validateAccessControlAllowOrigin)}, {"metrics.username", "PIXA_METRICS_USERNAME", field(&c.MetricsUsername, getString, "", nil)}, {"metrics.password", "PIXA_METRICS_PASSWORD", field(&c.MetricsPassword, getString, "", nil)}, {"sentry_dsn", "PIXA_SENTRY_DSN", field(&c.SentryDSN, getString, "", validateSentryDSN)}, {"debug", "PIXA_DEBUG", field(&c.Debug, getBool, false, nil)}, {"maintenance_mode", "PIXA_MAINTENANCE_MODE", field(&c.MaintenanceMode, getBool, false, nil)}, } } // field returns the load function of a setting that fills *dst. read // reads a value that is set, and so gives the setting's type. A setting // that is not set takes defaultValue. The value, a default too, must // then pass check, unless check is nil. func field[T any]( dst *T, read func(v value) (T, error), defaultValue T, check func(key string, val T) error, ) func(v value) error { return func(v value) error { val := defaultValue if v.present { var err error val, err = read(v) if err != nil { return err } } if check != nil { err := check(v.key, val) if err != nil { return err } } *dst = val return nil } } // loadDBURL loads db_url. An omitted db_url is derived from state_dir, // so db_url comes after state_dir in the table. An empty db_url is // refused, since defaults apply only to omitted keys. func (c *Config) loadDBURL(v value) error { if !v.present { c.DBURL = defaultDBURL(c.StateDir) return nil } dbURL, err := getString(v) if err != nil { return err } if dbURL == "" { return fmt.Errorf("%s: %w; omit it to derive it from state_dir", settingName(v.key), errValueEmpty) } c.DBURL = dbURL return nil } // loadCacheMaxBytes loads cache_max_bytes, and records whether it is set. // For an omitted cache_max_bytes pixad works out DefaultCacheMaxBytes // when it builds the cache. func (c *Config) loadCacheMaxBytes(v value) error { c.CacheMaxBytesExplicit = v.present return field(&c.CacheMaxBytes, getInt64, 0, validateNotNegative)(v) } // defaultDBURL returns the db_url used when db_url is omitted: the file // state.sqlite3 in stateDir, in WAL mode, which the driver sets only // through a _pragma parameter. String() percent-encodes any %, ? or # in // the directory name, which SQLite decodes back, so the URL names exactly // this file. OmitHost writes file:/var/lib/pixa/... instead of // file:///var/lib/pixa/..., and keeps a relative stateDir from being read // as a host name. The path written into the URL is stateDir followed by // /state.sqlite3; when it starts with //, as it does for a stateDir of / // or one starting with //, the empty host is kept, so the URL starts // file://// and SQLite does not read the first name in the path as a // host. func defaultDBURL(stateDir string) string { dbPath := stateDir + "/state.sqlite3" dbURL := url.URL{ Scheme: "file", OmitHost: !strings.HasPrefix(dbPath, "//"), Path: dbPath, RawQuery: "_pragma=journal_mode(WAL)", } return dbURL.String() } // 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. A setting whose key has a dot, such as // metrics.username, is written inside the map of the key's first part. // The env section is permitted because smartconfig consumes it for // environment variable injection. A nil sc means no config file, which // has no keys to check. func validateKnownKeys(sc *smartconfig.Config) error { if sc == nil { return nil } topLevelKeys := map[string]bool{"env": true} mapKeys := map[string]bool{} keysInMaps := map[string]bool{} for _, s := range new(Config).settings() { mapKey, _, inMap := strings.Cut(s.key, ".") topLevelKeys[mapKey] = true if inMap { mapKeys[mapKey] = true keysInMaps[s.key] = true } } var unknown, nullKeys []string for key, val := range sc.Data() { switch { case !topLevelKeys[key]: unknown = append(unknown, key) case val == nil: nullKeys = append(nullKeys, key) case mapKeys[key]: settingsMap, ok := val.(map[string]any) if !ok { return fmt.Errorf("config key %q: value %v is %w of %s settings", key, val, errNotAMap, key) } for subkey, subvalue := range settingsMap { fullKey := key + "." + subkey switch { case !keysInMaps[fullKey]: unknown = append(unknown, fullKey) case subvalue == nil: nullKeys = append(nullKeys, fullKey) } } } } return unknownOrNullKeysError(unknown, nullKeys) } // unknownOrNullKeysError reports the unknown keys validateKnownKeys // found, or else the keys set to null. It returns nil when there are // neither. func unknownOrNullKeysError(unknown, nullKeys []string) error { 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) } // 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 after loading the config file, so the variables the // file's env section sets are checked too. func validateKnownEnvVars() error { known := map[string]bool{"PIXA_CONFIG_PATH": true} for _, s := range new(Config).settings() { known[s.envVar] = 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 s. Its 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, s setting) value { envValue, present := os.LookupEnv(s.envVar) if present { return value{key: s.key, raw: envValue, present: true} } if sc == nil { return value{key: s.key} } fileValue, present := sc.Get(s.key) return value{key: s.key, raw: fileValue, present: present} } // settingName names the setting key in an error message together with // its environment variable, since either one may have set the value. func settingName(key string) string { var envVar string for _, s := range new(Config).settings() { if s.key == key { envVar = s.envVar } } return fmt.Sprintf("config key %q (environment variable %s)", key, envVar) } // 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 name := settingName("state_dir") err := os.MkdirAll(c.StateDir, stateDirPerms) if err != nil { return fmt.Errorf("%s: cannot create directory %q: %w", name, 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", name, c.StateDir, err) } probePath := probe.Name() err = probe.Close() if err != nil { return fmt.Errorf("%s: cannot close probe file %q: %w", name, probePath, err) } err = os.Remove(probePath) if err != nil { return fmt.Errorf("%s: cannot remove probe file %q: %w", name, probePath, err) } return nil } // validateSigningKey checks that the signing key is present, long // enough, and not the public placeholder from configs/config.example.yml. // The key value itself is never echoed in error messages. func validateSigningKey(key, signingKey string) error { if signingKey == "" { return fmt.Errorf("%s: %w", settingName(key), errValueRequired) } // Minimum key length for security (32 bytes = 256 bits) const minKeyLength = 32 if len(signingKey) < minKeyLength { return fmt.Errorf("%s: %w: must be at least %d characters, got %d", settingName(key), errValueTooShort, minKeyLength, len(signingKey)) } if signingKey == placeholderSigningKey { return fmt.Errorf("%s: %w", settingName(key), errPlaceholderKey) } return nil } // validatePort checks that a port is from 1 to 65535. func validatePort(key string, port int) error { const maxPort = 65535 if port < 1 || port > maxPort { return fmt.Errorf("%s: value %d is %w 1-%d", settingName(key), port, errPortOutOfRange, maxPort) } return nil } // validateNotEmpty checks that a value is not empty. func validateNotEmpty(key, val string) error { if val == "" { return fmt.Errorf("%s: %w", settingName(key), errValueEmpty) } return nil } // validateAtLeastOne checks that a limit, such as the most images // processed at once, is at least 1. func validateAtLeastOne(key string, limit int) error { if limit < 1 { return fmt.Errorf("%s: value %d %w", settingName(key), limit, errMustBeAtLeastOne) } return nil } // validateNotNegative checks that a number is not negative. Zero is valid // for the two settings that use it: it turns the disk cache or the limit // on image requests off. func validateNotNegative[T int | int64](key string, number T) error { if number < 0 { return fmt.Errorf("%s: value %d %w", settingName(key), number, errMustNotBeNegative) } return nil } // validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme // and a host. func validateSentryDSN(key, dsn string) error { if dsn == "" { return nil } parsed, err := url.Parse(dsn) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("%s: value %q is %w", settingName(key), dsn, errNotAValidURL) } return nil } // validateUpstreamMaxResponseSize checks that upstream_max_response_size // is from 1 byte to 1 GiB. An upstream response is read whole into // memory, and the image processor reads one byte past this limit, which // must not overflow. func validateUpstreamMaxResponseSize(key string, size int64) error { const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB if size < 1 || size > maxUpstreamMaxResponseSize { return fmt.Errorf("%s: value %d is %w 1-%d", settingName(key), size, errSizeOutOfRange, maxUpstreamMaxResponseSize) } return nil } // validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser // sends it: http or https, an IP address as netip writes it or a lowercase name // with a letter in its last part, and an optional port 1-65535, not the default. func validateAccessControlAllowOrigin(key, origin string) error { if origin == "*" { return nil } errOrigin := fmt.Errorf("%s: value %q is %w", settingName(key), origin, errNotAnOrigin) parsed, err := url.Parse(origin) if err != nil { return errOrigin } defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme] if defaultPort == "" { return errOrigin } const letters = "abcdefghijklmnopqrstuvwxyz" host := parsed.Hostname() lastPart := host[strings.LastIndex(host, ".")+1:] addr, err := netip.ParseAddr(host) switch { case err == nil && addr.Is6(): host = "[" + addr.String() + "]" case err == nil: host = addr.String() case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these return errOrigin case !strings.ContainsAny(lastPart, letters): return errOrigin } // The value must be exactly the origin rebuilt from its parts. rebuilt := parsed.Scheme + "://" + host port := parsed.Port() if port != "" { _, err := strconv.ParseUint(port, 10, 16) if err != nil || port[0] == '0' || port == defaultPort { return errOrigin } rebuilt += ":" + port } if rebuilt != origin { return errOrigin } return nil } // hostNamePattern matches a host name: letters, digits, hyphens, underscores // and dots, optionally after one leading dot. var hostNamePattern = regexp.MustCompile(`^\.?[A-Za-z0-9_-][A-Za-z0-9_.-]*$`) // validateHostPattern checks that an entry of the named key, allowlist_hosts // or referer_blocklist, is an IP address or a host name, the host name // optionally with one leading dot for suffix matching. Anything else, such as // a URL, a port or a "*." wildcard, can never match a host name that resolves, // so it is refused. // So is "." alone: the allowlist matcher would match it against any host // written with a trailing dot, which in allowlist_hosts disables URL signing. func validateHostPattern(key, host string) error { _, err := netip.ParseAddr(host) if err == nil || hostNamePattern.MatchString(host) { return nil } return fmt.Errorf("%s: entry %q %w", settingName(key), host, errNotAHost) } // 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) // Only a config file that does not exist is skipped, including // one whose path runs through a file, such as under a HOME of // /dev/null. One that cannot be read or does not parse is a // fatal startup error. //nolint:gosec // G703: a fixed config location; HOME is the operator's _, statErr := os.Stat(cleanPath) if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) { continue } if statErr != nil { return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr) } 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) } // getString returns the string v holds. A value that is not a string, or // is explicitly null, is an error. func getString(v value) (string, error) { if v.raw == nil { return "", errNullConfigValue(v.key) } str, ok := v.raw.(string) if !ok { return "", fmt.Errorf("config key %q: value %v (%T) is %w", v.key, v.raw, v.raw, errNotAString) } return str, nil } // getInt returns the integer v holds. A value that is not a whole // number, or is explicitly null, is an error; fractional values are // never truncated. func getInt(v value) (int, error) { if v.raw == nil { return 0, errNullConfigValue(v.key) } switch val := v.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", v.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(v.key), val, errNotAnInteger) } return parsed, nil default: return 0, fmt.Errorf("config key %q: value %v (%T) is %w", v.key, v.raw, v.raw, errNotAnInteger) } } // getInt64 returns the 64-bit integer v holds. A 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(v value) (int64, error) { if v.raw == nil { return 0, errNullConfigValue(v.key) } switch val := v.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", v.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", v.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(v.key), val, errNotAnInteger) } return parsed, nil default: return 0, fmt.Errorf("config key %q: value %v (%T) is %w", v.key, v.raw, v.raw, errNotAnInteger) } } // getDuration returns the duration v holds. The value must be a positive // Go duration string such as "30s" or "2m", read with // time.ParseDuration; a bare number has no unit and is an error, as is // an explicit null. func getDuration(v value) (time.Duration, error) { if v.raw == nil { return 0, errNullConfigValue(v.key) } str, ok := v.raw.(string) if !ok { return 0, fmt.Errorf("config key %q: value %v (%T) is %w", v.key, v.raw, v.raw, errNotADuration) } parsed, err := time.ParseDuration(strings.TrimSpace(str)) if err != nil { return 0, fmt.Errorf("%s: value %q is %w", settingName(v.key), str, errNotADuration) } if parsed <= 0 { return 0, fmt.Errorf("%s: value %q %w", settingName(v.key), str, errMustBePositive) } return parsed, nil } // getBool returns the boolean v holds. A 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(v value) (bool, error) { if v.raw == nil { return false, errNullConfigValue(v.key) } switch val := v.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(v.key), val, errNotABoolean) } return parsed, nil default: return false, fmt.Errorf("config key %q: value %v (%T) is %w", v.key, v.raw, v.raw, errNotABoolean) } } // 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 list v holds into CIDR prefixes. 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. // // An entry written in the IPv4-mapped form (::ffff:a.b.c.d) is kept as the // IPv4 range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. Both // blocked_networks and trusted_proxies are matched against an IPv4-mapped // address as the IPv4 address it holds, so the entry as written would match // nothing. Such an entry shorter than /96 covers more than the IPv4 // addresses and aborts startup. func parseCIDRList(v value) ([]netip.Prefix, error) { if v.raw == nil { return nil, errNullConfigValue(v.key) } entries, err := listEntries(v.raw, v.key) if err != nil { return nil, err } // An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by // the 32 bits of the IPv4 address it holds. const mappedPrefixBits = 96 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(v.key), entry, errNotAValidCIDR) } if prefix.Addr().Is4In6() && prefix.Bits() < mappedPrefixBits { return nil, fmt.Errorf("%s: value %q is %w", settingName(v.key), entry, errMappedTooShort) } if prefix.Addr().Is4In6() { prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-mappedPrefixBits) } prefixes = append(prefixes, prefix) } return prefixes, nil } // parseHostList parses the list v holds into host patterns. 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 entry // validateHostPattern rejects aborts startup naming the key and the // offending value. func parseHostList(v value) ([]string, error) { if v.raw == nil { return nil, errNullConfigValue(v.key) } entries, err := listEntries(v.raw, v.key) if err != nil { return nil, err } for _, entry := range entries { err := validateHostPattern(v.key, entry) if err != nil { return nil, err } } return entries, nil } // listEntries extracts the raw entries of the named 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. Any other shape is a // configuration error. func listEntries(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) } }