// Package config loads application configuration from environment variables. package config import ( "errors" "fmt" "log/slog" "net/netip" "os" "strconv" "strings" "time" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" // Populates the environment from a ./.env file automatically for // development configuration. Kept in one place only (here). _ "github.com/joho/godotenv/autoload" ) const ( // EnvironmentDev represents development environment. EnvironmentDev = "dev" // EnvironmentProd represents production environment. EnvironmentProd = "prod" // DefaultDataDir is where all SQLite databases live when DATA_DIR // is unset. The same default applies in every environment. DefaultDataDir = "/var/lib/webhooker" // defaultPort is the default HTTP listen port. defaultPort = 8080 // defaultRetentionSweepInterval is how often the retention // reaper deletes events older than each webhook's RetentionDays. defaultRetentionSweepInterval = time.Hour // defaultSessionIdleTimeout is how long a session may go without // authenticated activity before it expires. defaultSessionIdleTimeout = 24 * time.Hour // defaultReceiverRateLimit is the default number of requests // per minute each client IP may send to a single webhook // receiver entrypoint. Generous for legitimate webhook // senders while bounding abuse of the one unauthenticated, // internet-exposed endpoint. defaultReceiverRateLimit = 120 // maxPort is the highest valid TCP port number. The lower // bound (at least 1) is enforced by envPositiveInt. maxPort = 65535 // mappedV4Offset is the number of leading bits an IPv4-mapped // IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104 // covers the same addresses as an IPv4 /8. mappedV4Offset = 96 ) // ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT // contains an unrecognised value. var ErrInvalidEnvironment = errors.New("invalid environment") // ErrNonPositiveValue is returned when an environment variable that // requires a positive integer is set to zero or a negative number. var ErrNonPositiveValue = errors.New("value must be positive") // ErrInvalidPort is returned when an environment variable holding a // TCP port number is set above the valid port range. var ErrInvalidPort = errors.New("invalid port") // ErrInvalidCIDR is returned when an environment variable holding a // list of CIDR blocks contains an entry that is neither a CIDR block // nor a bare IP address. var ErrInvalidCIDR = errors.New("invalid CIDR") // ErrIncompleteMetricsAuth is returned when exactly one of // METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither // fallback is acceptable: serving /metrics on the username alone // publishes an endpoint whose password is the empty string, and // silently leaving it unmounted withholds an endpoint the operator // asked for. Half-set is a configuration error, so startup fails. var ErrIncompleteMetricsAuth = errors.New( "incomplete metrics credentials", ) //nolint:revive // ConfigParams is a standard fx naming convention. type ConfigParams struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds all application configuration loaded from // environment variables. type Config struct { DataDir string Debug bool MaintenanceMode bool Environment string MetricsPassword string MetricsUsername string Port int SentryDSN string // RetentionSweepInterval is how often the retention reaper runs. // Always positive: it becomes a time.NewTicker period. RetentionSweepInterval time.Duration // SessionIdleTimeout is the sliding inactivity window after // which a session expires. Non-positive disables idle expiry. SessionIdleTimeout time.Duration // ReceiverRateLimit is the number of requests per minute each // client IP may send to a single webhook receiver entrypoint. ReceiverRateLimit int // TrustedProxies is the set of networks whose members are // allowed to speak for the client with X-Forwarded-For, the // only forwarded header read. It is empty unless // TRUSTED_PROXIES is set, and empty means no peer is // trusted: forwarded headers are then ignored entirely and // clients are identified by the connection's own address. // Members can choose their own rate-limit key, so this must // name proxy hosts only, never a block that also covers // clients. TrustedProxies []netip.Prefix // AllowedEgressCIDRs is the set of networks a delivery target // may reach even though the SSRF guard's default blocklist // covers them. It is empty unless ALLOWED_EGRESS_CIDRS is set, // and empty means every private/reserved range stays refused. // // This only ever adds destinations to what the guard would // otherwise refuse. The guard itself is always on: there is no // setting that disables SSRF protection, and delivery's // alwaysBlockedNetworks — link-local plus every known cloud // metadata endpoint outside it — stays blocked no matter what // is listed here. AllowedEgressCIDRs []netip.Prefix params *ConfigParams log *slog.Logger } // IsDev returns true if running in development environment. func (c *Config) IsDev() bool { return c.Environment == EnvironmentDev } // IsProd returns true if running in production environment. func (c *Config) IsProd() bool { return c.Environment == EnvironmentProd } // MetricsAuthEnabled reports whether /metrics is served behind basic // auth. It is the only answer to that question in the codebase: the // route mount, the Prometheus recording middleware and the startup // log's hasMetricsAuth field all read this one method, so the log // cannot report auth as off while the route is mounted. // // It requires both credentials rather than the username alone. // loadFromEnv already rejects a half-set pair, but a Config built in // code bypasses that, and the failure mode this guards is an endpoint // mounted with a credential map whose only password is the empty // string. func (c *Config) MetricsAuthEnabled() bool { return c.MetricsUsername != "" && c.MetricsPassword != "" } // envString returns the value of the named environment variable, // or an empty string if not set. func envString(key string) string { return os.Getenv(key) } // DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset // or empty. It is exported so that entry points which must act on the // data directory before the fx graph exists — taking the exclusive // directory lock, above all — resolve it exactly as Config does. func DataDir() string { dir := envString("DATA_DIR") if dir == "" { return DefaultDataDir } return dir } // envBool returns the value of the named environment variable // parsed as a boolean. Returns defaultValue if not set. If the // variable is set but cannot be parsed, it returns a wrapped error // naming the key and the bad value, so startup fails loudly rather // than silently falling back to the default. // // Parsing is strconv.ParseBool, which accepts 1, t, T, TRUE, true, // True, 0, f, F, FALSE, false and False. Anything else — "yes", // "on", or a typo like "ture" — is an error rather than a silent // false. func envBool(key string, defaultValue bool) (bool, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf( "invalid boolean for %s: %q: %w", key, v, err, ) } return b, nil } // envPositiveInt returns the value of the named environment variable // parsed as a positive integer. Returns defaultValue if not set. If // the variable is set but cannot be parsed, or parses to less than // one, it returns a wrapped error naming the key and the bad value, // so startup fails loudly rather than silently falling back to the // default. func envPositiveInt( key string, defaultValue int, ) (int, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } i, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf( "invalid integer for %s: %q: %w", key, v, err, ) } if i < 1 { return 0, fmt.Errorf( "%w: %s must be at least 1, got %q", ErrNonPositiveValue, key, v, ) } return i, nil } // envPort returns the value of the named environment variable parsed // as a TCP port number. Returns defaultValue if not set. A set value // that is unparseable, below 1, or above maxPort is a hard error // naming the key and the bad value. func envPort(key string, defaultValue int) (int, error) { port, err := envPositiveInt(key, defaultValue) if err != nil { return 0, err } if port > maxPort { return 0, fmt.Errorf( "%w: %s must be at most %d, got %d", ErrInvalidPort, key, maxPort, port, ) } return port, nil } // envDuration returns the value of the named environment variable // parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if // not set. If the variable is set but cannot be parsed, it returns a // wrapped error naming the key and the bad value, so startup fails // loudly rather than silently falling back to the default. func envDuration( key string, defaultValue time.Duration, ) (time.Duration, error) { v := os.Getenv(key) if v == "" { return defaultValue, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf( "invalid duration for %s: %q: %w", key, v, err, ) } return d, nil } // envPositiveDuration returns the value of the named environment // variable parsed as a Go duration that must be greater than zero. // Returns defaultValue if not set. A set value that is unparseable or // non-positive is a hard error naming the key and the bad value. // // This is for durations that reach time.NewTicker, which panics on a // non-positive period, in a goroutine started after startup has // already reported success. It is deliberately not used for durations // where non-positive means "disabled" (SESSION_IDLE_TIMEOUT). func envPositiveDuration( key string, defaultValue time.Duration, ) (time.Duration, error) { d, err := envDuration(key, defaultValue) if err != nil { return 0, err } if d <= 0 { return 0, fmt.Errorf( "%w: %s must be greater than zero, got %s", ErrNonPositiveValue, key, d, ) } return d, nil } // parseCIDR parses one trusted-proxy list entry, which may be a // CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated // as a single-host block). // // Both forms are unmapped, because peer addresses are unmapped // before they are matched against the list: an IPv4-mapped prefix // left in that form would silently never match. func parseCIDR(entry string) (netip.Prefix, error) { if strings.Contains(entry, "/") { prefix, err := netip.ParsePrefix(entry) if err != nil { return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller } if addr := prefix.Addr(); addr.Is4In6() && prefix.Bits() >= mappedV4Offset { prefix = netip.PrefixFrom( addr.Unmap(), prefix.Bits()-mappedV4Offset, ) } return prefix.Masked(), nil } addr, err := netip.ParseAddr(entry) if err != nil { return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller } return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil } // envPrefixList returns the value of the named environment variable // parsed as a comma-separated list of CIDR blocks (bare addresses // allowed). An unset, empty, or blank value yields an empty list. A // set value containing an unparseable entry is a hard error naming // the key and the bad entry, so startup fails loudly rather than // silently running with a list the operator did not intend. func envPrefixList(key string) ([]netip.Prefix, error) { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return nil, nil } var prefixes []netip.Prefix for entry := range strings.SplitSeq(v, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } prefix, err := parseCIDR(entry) if err != nil { return nil, fmt.Errorf( "%w: %s: %q: %w", ErrInvalidCIDR, key, entry, err, ) } prefixes = append(prefixes, prefix) } return prefixes, nil } // resolveMetricsAuth reads the /metrics basic-auth credentials and // rejects a half-set pair, naming both variables either way. The // error carries neither value: the password is a secret. func resolveMetricsAuth() (string, string, error) { username := envString("METRICS_USERNAME") password := envString("METRICS_PASSWORD") if (username == "") == (password == "") { return username, password, nil } set, empty := "METRICS_USERNAME", "METRICS_PASSWORD" if username == "" { set, empty = empty, set } return "", "", fmt.Errorf( "%w: %s is set but %s is empty; METRICS_USERNAME and "+ "METRICS_PASSWORD must both be set to serve /metrics, "+ "or both be empty to leave it unmounted", ErrIncompleteMetricsAuth, set, empty, ) } // resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to // dev, and rejects unrecognised values. func resolveEnvironment() (string, error) { environment := os.Getenv("WEBHOOKER_ENVIRONMENT") if environment == "" { environment = EnvironmentDev } if environment != EnvironmentDev && environment != EnvironmentProd { return "", fmt.Errorf( "%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'", ErrInvalidEnvironment, EnvironmentDev, EnvironmentProd, environment, ) } return environment, nil } // loadFromEnv builds a Config from the environment. Every value that // needs parsing fails loudly when it is set but unparseable: the // documented defaults apply only to variables that are unset (or // empty), never as a substitute for a value the operator actually // provided. func loadFromEnv() (*Config, error) { environment, err := resolveEnvironment() if err != nil { return nil, err } port, err := envPort("PORT", defaultPort) if err != nil { return nil, err } debug, err := envBool("DEBUG", false) if err != nil { return nil, err } maintenanceMode, err := envBool("MAINTENANCE_MODE", false) if err != nil { return nil, err } retentionSweepInterval, err := envPositiveDuration( "RETENTION_SWEEP_INTERVAL", defaultRetentionSweepInterval, ) if err != nil { return nil, err } // Non-positive is "disabled" here, not invalid, so this stays on // envDuration. sessionIdleTimeout, err := envDuration( "SESSION_IDLE_TIMEOUT", defaultSessionIdleTimeout, ) if err != nil { return nil, err } receiverRateLimit, err := envPositiveInt( "RECEIVER_RATE_LIMIT", defaultReceiverRateLimit, ) if err != nil { return nil, err } trustedProxies, err := envPrefixList("TRUSTED_PROXIES") if err != nil { return nil, err } allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS") if err != nil { return nil, err } metricsUsername, metricsPassword, err := resolveMetricsAuth() if err != nil { return nil, err } return &Config{ DataDir: DataDir(), Debug: debug, MaintenanceMode: maintenanceMode, Environment: environment, MetricsUsername: metricsUsername, MetricsPassword: metricsPassword, Port: port, SentryDSN: envString("SENTRY_DSN"), RetentionSweepInterval: retentionSweepInterval, SessionIdleTimeout: sessionIdleTimeout, ReceiverRateLimit: receiverRateLimit, TrustedProxies: trustedProxies, AllowedEgressCIDRs: allowedEgressCIDRs, }, nil } // PrefixStrings renders a prefix list as its CIDR strings, for // logging a list an operator has to be able to read back. func PrefixStrings(prefixes []netip.Prefix) []string { out := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { out = append(out, prefix.String()) } return out } // warnEgressAllowlist logs the effective ALLOWED_EGRESS_CIDRS // whenever it is non-empty. // // It prints the blocks themselves rather than a count, because // this is the one setting that lets a delivery target reach the // host's own network: an operator reading the startup log has to // be able to see exactly which hole is open. Silence means the // list is empty and the SSRF guard is refusing every // private/reserved range, which is the default. func (c *Config) warnEgressAllowlist(log *slog.Logger) { if len(c.AllowedEgressCIDRs) == 0 { return } log.Warn( "ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+ "otherwise-blocked private/reserved networks. Anyone "+ "who can create a delivery target can now make this "+ "process issue requests into them, and read back the "+ "response. Link-local and the known cloud instance "+ "metadata endpoints outside it stay blocked "+ "regardless of what is listed here.", "allowedEgressCIDRs", strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","), ) } // warnSharedRateLimitBucket logs a startup warning whenever // TRUSTED_PROXIES is empty, in any environment. // // With no trusted proxies every rate limiter keys on the connecting // peer's address. Whether that is harmless or dangerous depends on // what is in front of the process, which this code cannot observe: // with nothing in front, the peer is the client and the limits are // per-client as intended; behind a reverse proxy the peer is the proxy // for every request, so all clients share one bucket per limiter. // // The login endpoint no longer spends budget on arrival — it verifies // credentials first and charges only failures — so a shared bucket // cannot deny the operator a correct password. What it does collapse // is the failure counting: one client's wrong passwords throttle // everyone else's wrong passwords, and the receiver's limits become // service-wide ceilings. // // The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That // variable defaults to dev, so gating on it would silence the warning // for exactly the operator who forgot to configure the deployment — // the case it exists to catch. // // The default of trusting nobody is deliberate — trusting forwarded // headers from arbitrary peers lets any client choose its own bucket — // so this warns rather than failing startup or changing the key. func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) { if len(c.TrustedProxies) > 0 { return } log.Warn( "TRUSTED_PROXIES is empty: every rate limit keys on the "+ "connecting peer's address. With nothing proxying to "+ "this process that is the client itself and the limits "+ "are per-client as intended. Behind a reverse proxy the "+ "peer is the proxy on every request, so all clients "+ "share one bucket per limit: the receiver limits become "+ "service-wide ceilings, and one client's failed logins "+ "throttle every other client's failed logins — a "+ "correct password still gets in. If anything proxies to "+ "this process, set TRUSTED_PROXIES to its address.", "environment", c.Environment, "trustedProxies", len(c.TrustedProxies), ) } // New creates a Config by reading environment variables. // //nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { log := params.Logger.Get() // A set-but-unparseable value anywhere in the environment is a // hard error, so fx aborts startup rather than running with a // silently substituted default. s, err := loadFromEnv() if err != nil { return nil, err } s.log = log s.params = ¶ms if s.Debug { params.Logger.EnableDebugLogging() } // Log configuration summary (without secrets) log.Info("Configuration loaded", "environment", s.Environment, "port", s.Port, "debug", s.Debug, "maintenanceMode", s.MaintenanceMode, "dataDir", s.DataDir, "retentionSweepInterval", s.RetentionSweepInterval.String(), // Logged because a perfectly valid non-positive value here // disables idle expiry entirely, and that is worth showing // back to the operator. "sessionIdleTimeout", s.SessionIdleTimeout.String(), "receiverRateLimit", s.ReceiverRateLimit, "trustedProxies", len(s.TrustedProxies), "allowedEgressCIDRs", len(s.AllowedEgressCIDRs), "hasSentryDSN", s.SentryDSN != "", "hasMetricsAuth", s.MetricsAuthEnabled(), ) s.warnSharedRateLimitBucket(log) s.warnEgressAllowlist(log) return s, nil }