All checks were successful
check / check (push) Successful in 2m59s
The empty-TRUSTED_PROXIES warning was gated on IsProd(), but WEBHOOKER_ENVIRONMENT defaults to dev, so an internet-exposed deployment whose operator never set it got no warning at all — the exact operator error the warning exists to catch. It now fires whenever the list is empty, in any environment, and its text is accurate both behind a reverse proxy (shared buckets, remotely deniable admin login) and with nothing in front of the process (harmless). The startup configuration summary also now logs sessionIdleTimeout, the one value where a valid setting silently disables a security control. The README documented a two-stage Docker build on golang:1.24 running "make check" (the tree has three stages: a golangci-lint lint stage running fmt-check and lint, a golang:1.26.1-bookworm builder running test and build, then the Alpine runtime), advertised the public receiver as accepting all methods (it answers 405 to everything but POST), claimed unqualified per-IP login rate limiting, and left the session-expiry prose orphaned inside the trusted-proxy subsection. The rest of the README was swept against the code rather than only the reported lines: every documented route checked method-by-method against internal/server/routes.go (adding the password-change, entrypoint and target routes that were missing), every environment variable checked against internal/config/config.go (MAINTENANCE_MODE serves no maintenance page — it only sets a healthcheck field), the fx wiring, package tree, prerequisites and dev commands brought back in line with the tree. Statements the sweep had waved through, each re-derived from the code this time: - Circuit breakers are not HTTP-only. target_slack.go embeds httpCore and hands its own MaxRetries to the same retry path, so a slack target with max_retries > 0 gets a breaker on the same defaults. - No WAL exists. Both DSNs are file:%s?cache=shared&mode=rwc and no journal_mode pragma is issued anywhere, so every database runs on SQLite's rollback journal. - Slack config is keyed webhookUrl, not webhook_url; the underscored spelling is only the error message. An operator copying the README wrote config that was silently ignored. - Setting carries no BaseModel, so neither the common-field list nor "soft deletes on all entities" held for it. - DeliveryTask names no type; it is delivery.Task. - script/lint runs golangci-lint on the host. Only script/cibuild and script/docker involve Docker; #109 tracks changing that, and until it lands the README says what the tree does. - An entrypoint's path column holds a bare UUID. The /webhook/ prefix is route only and never stored. - max_queue_size is stored and displayed but consulted by nothing; queue depth is the two fixed 10,000-entry channels. - Inline event bodies are cut at "< 16 KiB", not "≤". - retention_days 0 belongs to the retain-forever band — BeforeSave rewrites it to the sentinel — not the finite one, whose floor is 1. A negative value is a 400 in parseRetentionDays. - The receiver's per-client limit keys on the request path (httprate.KeyByEndpoint), not on the entrypoint; the paragraph eight lines below already said so and the two contradicted each other. - Shutdown goes through lifecycle.WaitForShutdown, which is bounded by fx's stop context and can return with goroutines still running, rather than a bare WaitGroup.Wait(). - Nine of the Makefile's fifteen targets shim script/; build, run, dev, deps, clean and css are inline. - Nine entities are documented and nine model files exist, not eight. - The metrics middleware is only registered when METRICS_USERNAME is set, so an over-limit request is not always counted. Two gaps closed while checking: the target-type list omitted slack entirely, and the package tree omitted event_log_view.go and the three testing.go files, which are ordinary compiled sources exporting NewTestDatabase and NewForTest rather than _test.go scaffolding. TestStaticServesEveryMethod settles what /s/* actually answers: chi's Mount registers every method and http.FileServer special-cases only HEAD, so POST, PUT and DELETE to an asset are served the file. The README claimed GET and HEAD. TODO.md drops the unsupported half of its CI claim, keeping the cache-defeated container runs, and splits the landed password change away from the unimplemented reset flow. Rebasing onto current next moved the tree under three of these sections, so they were re-derived against the rebased tree rather than the one the branch was cut from: alpine.min.js is no longer committed but fetched and hash-verified by script/fetch-assets, so the package tree entry for it was wrong and vendor.sha256 was absent; the builder stage runs that script before make test; the Makefile is now ten shims out of sixteen targets, and fmt-check was missing from the development command list. The Quick Start was also wrong in a way that costs a new contributor a red build: it said make deps, which only runs go mod download and tidy, and make check then fails two tests on the absent asset. It now says make bootstrap, which fetches it.
519 lines
15 KiB
Go
519 lines
15 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"
|
|
|
|
// 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")
|
|
|
|
//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
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
return &Config{
|
|
DataDir: envString("DATA_DIR"),
|
|
Debug: debug,
|
|
MaintenanceMode: maintenanceMode,
|
|
Environment: environment,
|
|
MetricsUsername: envString("METRICS_USERNAME"),
|
|
MetricsPassword: envString("METRICS_PASSWORD"),
|
|
Port: port,
|
|
SentryDSN: envString("SENTRY_DSN"),
|
|
RetentionSweepInterval: retentionSweepInterval,
|
|
SessionIdleTimeout: sessionIdleTimeout,
|
|
ReceiverRateLimit: receiverRateLimit,
|
|
TrustedProxies: trustedProxies,
|
|
}, nil
|
|
}
|
|
|
|
// 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 limiter's bucket is the dangerous one: any remote client can
|
|
// keep it full, which denies the only administrative login to everyone
|
|
// until the process restarts.
|
|
//
|
|
// 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 and any remote client can "+
|
|
"keep the login limit full, denying the admin login — "+
|
|
"the only administrative path — until restart. 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
|
|
|
|
// Set default DataDir. All SQLite databases (main application
|
|
// DB and per-webhook event DBs) live here. The same default is
|
|
// used regardless of environment; override with DATA_DIR if
|
|
// needed.
|
|
if s.DataDir == "" {
|
|
s.DataDir = "/var/lib/webhooker"
|
|
}
|
|
|
|
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),
|
|
"hasSentryDSN", s.SentryDSN != "",
|
|
"hasMetricsAuth",
|
|
s.MetricsUsername != "" && s.MetricsPassword != "",
|
|
)
|
|
|
|
s.warnSharedRateLimitBucket(log)
|
|
|
|
return s, nil
|
|
}
|