Files
pixa/internal/config/config.go
T
sneak fdcf1a3c61
check / check (push) Canceled after 0s
Load every setting from one table in internal/config (closes #320)
Config.settings lists each setting once: its key, its environment
variable and the function that loads it. Most rows load through field,
whose arguments give the type, the default and the check. db_url and
cache_max_bytes keep their own load functions. Loading, the unknown-key
and unknown-variable checks and the names in error messages all read
the table. allowlist_hosts now loads through parseHostList, as
referer_blocklist does. isKnownConfigKey, envVarNames, strictLoader,
validate, validateAllowlistHostsValue and getStringSlice are gone, with
the getStringSlice tests. The key constants move to the tests, their
only users. A new test checks that README.md and
configs/config.example.yml name the keys of the table.

Model: opus-5-5
2026-10-26 20:49:38 +00:00

1167 lines
36 KiB
Go

// 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)
}
}