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. isKnownConfigKey, envVarNames, strictLoader, validate, validateAllowlistHostsValue and getStringSlice are gone, with the getStringSlice tests. The key constants move to the tests, their only users. README.md lists the settings one item each instead of in a table, with shorter sentences above the list. A new test checks that this list and configs/config.example.yml name exactly 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)
|
|
}
|
|
}
|