check / check (push) Canceled after 0s
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
1167 lines
36 KiB
Go
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)
|
|
}
|
|
}
|