All checks were successful
check / check (push) Successful in 3m46s
The plaintext listener bound `:PORT`, so it answered on every interface with no way to say otherwise. That published the admin UI and the unauthenticated receiver in cleartext beside whatever TLS proxy was in front of them, reachable from any host that could route to the machine. BIND_ADDRESS now selects the address. The binary defaults to 127.0.0.1, which is the safe answer for a bare host: reaching webhooker from elsewhere becomes a deliberate act. The image sets 0.0.0.0, which is the correct answer inside a container, where the network namespace is already the boundary and exposure is decided by the publish flag instead — so `-p 127.0.0.1:8080:8080` is what the README shows. Existing container deployments are unaffected. Only IP address literals are accepted: hostnames, host:port and CIDR blocks abort startup naming the variable and the value, and a literal that is not an address of this host fails at listen and exits non-zero. The http.Server is now built in New rather than in the serving goroutine, and sentryEnabled is atomic. Both fields were written by the serving goroutine and read by the fx stop hook with nothing ordering them, and the OnStart hook returns before that goroutine has necessarily run: cleanShutdown could dereference a nil httpServer on an early SIGTERM, and both reads raced. No test started and stopped the server, so nothing observed it. Closes #226. README gains a "Deployment behind a reverse proxy" section: a working nginx server block, and the five things that are silent when wrong — bind or firewall the app port, WEBHOOKER_ENVIRONMENT=prod, TRUSTED_PROXIES, Host as $http_host rather than $host, and keeping the proxy's access log because webhooker's own records only the proxy.
743 lines
24 KiB
Go
743 lines
24 KiB
Go
// 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
|
|
|
|
// defaultBindAddress is the interface the plaintext HTTP
|
|
// listener claims when BIND_ADDRESS is unset.
|
|
//
|
|
// Loopback, because the listener speaks cleartext and serves
|
|
// both the admin UI and the unauthenticated receiver: a
|
|
// wildcard default publishes them on every interface of every
|
|
// host that never configured anything, which is the failure
|
|
// this default exists to prevent. Reaching webhooker from off
|
|
// the host is then a deliberate act — a reverse proxy in front
|
|
// of it, or an explicit BIND_ADDRESS.
|
|
//
|
|
// A container needs BIND_ADDRESS=0.0.0.0 set explicitly: a
|
|
// loopback-bound process is unreachable from outside its
|
|
// network namespace even with -p. That is deliberate. The
|
|
// container fails its healthcheck immediately and visibly,
|
|
// where the wildcard default fails silently in the direction of
|
|
// exposure. There is no container auto-detection here, because
|
|
// a heuristic that guesses wrong opens the cleartext port
|
|
// exactly where nobody is looking.
|
|
defaultBindAddress = "127.0.0.1"
|
|
|
|
// 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")
|
|
|
|
// ErrInvalidBindAddress is returned when BIND_ADDRESS is set to
|
|
// something that is not an IP address literal.
|
|
var ErrInvalidBindAddress = errors.New("invalid bind address")
|
|
|
|
// 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
|
|
|
|
// BindAddress is the IP address the plaintext HTTP listener
|
|
// binds, as an address literal. It defaults to
|
|
// defaultBindAddress and is never empty: an empty string would
|
|
// mean the wildcard to net.Listen, which is the opposite of the
|
|
// default this ships.
|
|
BindAddress 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 stays blocked no matter what is listed
|
|
// here. That set is link-local plus the cloud metadata
|
|
// endpoints outside it that disclose credentials or user data
|
|
// at a provider-fixed address; it is not exhaustive of every
|
|
// cloud's metadata address. See alwaysBlockedNetworks for the
|
|
// authoritative list and the criterion it is built from.
|
|
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
|
|
}
|
|
|
|
// envBindAddress returns the value of the named environment variable
|
|
// parsed as an IP address literal. An unset (or empty, or
|
|
// whitespace-only) value yields defaultValue.
|
|
//
|
|
// Only literals are accepted: no hostname is resolved, so `localhost`
|
|
// is an error rather than a DNS lookup at startup whose answer could
|
|
// be either loopback family, could change under the process, and
|
|
// could return several addresses of which only one would be bound. A
|
|
// value with a port in it (`127.0.0.1:8080`) is likewise an error —
|
|
// the port is PORT's business, and silently accepting it would bind
|
|
// something other than what was asked for.
|
|
//
|
|
// A set value that is not a literal is a hard error naming the key
|
|
// and the bad value, so startup fails loudly rather than falling back
|
|
// to a default the operator plainly did not want. A literal that is
|
|
// not an address of this host parses here and fails at listen time
|
|
// instead, which ends the process non-zero.
|
|
func envBindAddress(key, defaultValue string) (string, error) {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return defaultValue, nil
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(v)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"%w: %s: %q must be an IP address literal such as "+
|
|
"127.0.0.1, 0.0.0.0 or ::, not a hostname and not "+
|
|
"host:port: %w",
|
|
ErrInvalidBindAddress, key, v, err,
|
|
)
|
|
}
|
|
|
|
return addr.String(), 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
|
|
}
|
|
|
|
// resolveListener reads the two variables that describe the HTTP
|
|
// listener: which port it claims and which address it claims it on.
|
|
// They are read together because neither is meaningful alone, and
|
|
// because a validation failure in either has to abort startup before
|
|
// anything binds.
|
|
func resolveListener() (int, string, error) {
|
|
port, err := envPort("PORT", defaultPort)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
|
|
bindAddress, err := envBindAddress(
|
|
"BIND_ADDRESS", defaultBindAddress,
|
|
)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
|
|
return port, bindAddress, 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, bindAddress, err := resolveListener()
|
|
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,
|
|
BindAddress: bindAddress,
|
|
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,
|
|
// Logged because which interfaces the cleartext listener
|
|
// answers on is not otherwise observable from inside a
|
|
// container, and it decides whether anything but the local
|
|
// host can reach the admin UI.
|
|
"bindAddress", s.BindAddress,
|
|
"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
|
|
}
|