Bind the app port deliberately and document the proxy deployment (closes #268) (closes #226)
All checks were successful
check / check (push) Successful in 3m4s
All checks were successful
check / check (push) Successful in 3m4s
This commit was merged in pull request #277.
This commit is contained in:
@@ -33,6 +33,34 @@ const (
|
||||
// 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.
|
||||
//
|
||||
// This is the binary's default only. The Dockerfile ships
|
||||
// ENV BIND_ADDRESS=0.0.0.0, so a container deployment needs
|
||||
// nothing set and is unaffected by this constant. The two
|
||||
// differ because they answer different questions: a container's
|
||||
// network namespace is already the boundary this default is
|
||||
// reaching for, so binding every address inside it exposes
|
||||
// nothing, and what decides exposure there is the publish flag
|
||||
// (-p 127.0.0.1:8080:8080). A loopback bind inside a container
|
||||
// buys no security and makes the process unreachable through
|
||||
// its own published port.
|
||||
//
|
||||
// The split is expressed as two explicit defaults rather than
|
||||
// container auto-detection, 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
|
||||
@@ -75,6 +103,10 @@ var ErrInvalidPort = errors.New("invalid port")
|
||||
// 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
|
||||
@@ -105,6 +137,13 @@ type Config struct {
|
||||
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
|
||||
@@ -387,6 +426,42 @@ func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||
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.
|
||||
@@ -431,6 +506,27 @@ func resolveEnvironment() (string, error) {
|
||||
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
|
||||
@@ -442,7 +538,7 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := envPort("PORT", defaultPort)
|
||||
port, bindAddress, err := resolveListener()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -506,6 +602,7 @@ func loadFromEnv() (*Config, error) {
|
||||
MetricsUsername: metricsUsername,
|
||||
MetricsPassword: metricsPassword,
|
||||
Port: port,
|
||||
BindAddress: bindAddress,
|
||||
SentryDSN: envString("SENTRY_DSN"),
|
||||
RetentionSweepInterval: retentionSweepInterval,
|
||||
SessionIdleTimeout: sessionIdleTimeout,
|
||||
@@ -625,6 +722,11 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user