check / check (push) Waiting to run
The backend's own TRUSTED_PROXIES now follows the ruling for every app: unset, it trusts 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, and no longer loopback; a list replaces that; set and empty, it trusts no proxy. viper reads an empty environment variable as unset, so an empty value got the default. config.New now reads this one setting with os.LookupEnv first. viper's AllowEmptyEnv would apply to every setting, so an empty PORT or DEBUG would stop the start instead of counting as unset, as README.md says it does. bin/entrypoint.sh still starts the backend with TRUSTED_PROXIES=127.0.0.1/32. Model: opus-5-5
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
// Package config loads application configuration from
|
|
// environment variables, .env files, and config files.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/netwatch/internal/globals"
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
|
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
|
"github.com/spf13/viper"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// defaultTrustedProxies lists the networks whose forwarded
|
|
// headers are honoured when TRUSTED_PROXIES is unset: the
|
|
// RFC 1918 ranges. The container image does not use it:
|
|
// bin/entrypoint.sh gives the server 127.0.0.1/32, since
|
|
// nginx is its only client there.
|
|
const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
|
|
// Default limits on stored reports; README.md gives the reasons for
|
|
// these values.
|
|
const (
|
|
defaultReportsPerMinute = 60
|
|
defaultDataDirMaxBytes = 1 << 30 // 1 GiB
|
|
)
|
|
|
|
var (
|
|
errNotPositive = errors.New("must be a positive whole number")
|
|
errNotOrigin = errors.New(
|
|
"must be an origin, scheme://host with an optional port",
|
|
)
|
|
errNotPort = errors.New("must be a port number, 1 to 65535")
|
|
errNotBool = errors.New("must be true or false")
|
|
errNotIP = errors.New("must be an IP address, or empty")
|
|
|
|
errMetricsCredentials = errors.New(
|
|
"METRICS_USERNAME and METRICS_PASSWORD must be set together, " +
|
|
"or neither",
|
|
)
|
|
errMetricsUsernameColon = errors.New(
|
|
"METRICS_USERNAME must not contain \":\", " +
|
|
"which basic auth cannot carry in a user name",
|
|
)
|
|
)
|
|
|
|
// Params defines the dependencies for Config.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Globals *globals.Globals
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Config holds the resolved application configuration.
|
|
type Config struct {
|
|
BindAddress string
|
|
CORSAllowedOrigins []string
|
|
DataDir string
|
|
DataDirMaxBytes int64
|
|
Debug bool
|
|
MetricsPassword string
|
|
MetricsUsername string
|
|
Port int
|
|
ReportsPerMinute int
|
|
SentryDSN string
|
|
TrustedProxies []string
|
|
log *slog.Logger
|
|
params *Params
|
|
}
|
|
|
|
// New loads configuration from env, .env files, and config
|
|
// files, returning a fully resolved Config. It fails, with an error
|
|
// naming the setting, on a value the server cannot use, and on a
|
|
// config file it finds but cannot read.
|
|
func New(
|
|
_ fx.Lifecycle,
|
|
params Params,
|
|
) (*Config, error) {
|
|
log := params.Logger.Get()
|
|
name := params.Globals.Appname
|
|
|
|
viper.SetConfigName(name)
|
|
viper.SetConfigType("yaml")
|
|
viper.AddConfigPath("/etc/" + name)
|
|
viper.AddConfigPath("$HOME/.config/" + name)
|
|
|
|
viper.AutomaticEnv()
|
|
|
|
// An empty CORS_ALLOWED_ORIGINS allows no other origin.
|
|
viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
|
|
viper.SetDefault("DATA_DIR", "./data/reports")
|
|
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
|
|
viper.SetDefault("DEBUG", "false")
|
|
// An empty BIND_ADDRESS listens on every interface.
|
|
viper.SetDefault("BIND_ADDRESS", "")
|
|
viper.SetDefault("PORT", "8080")
|
|
viper.SetDefault("REPORTS_PER_MINUTE", defaultReportsPerMinute)
|
|
viper.SetDefault("SENTRY_DSN", "")
|
|
viper.SetDefault("METRICS_USERNAME", "")
|
|
viper.SetDefault("METRICS_PASSWORD", "")
|
|
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
|
|
|
|
err := viper.ReadInConfig()
|
|
if err != nil {
|
|
var notFound viper.ConfigFileNotFoundError
|
|
if !errors.As(err, ¬Found) {
|
|
return nil, fmt.Errorf("config file %s: %w",
|
|
viper.ConfigFileUsed(), err)
|
|
}
|
|
}
|
|
|
|
// Read with strconv: viper's GetInt and GetBool would read a value
|
|
// they cannot parse as 0 or false instead of failing.
|
|
port, err := strconv.Atoi(viper.GetString("PORT"))
|
|
if err != nil || port < 1 || port > math.MaxUint16 {
|
|
return nil, fmt.Errorf("PORT %q: %w",
|
|
viper.GetString("PORT"), errNotPort)
|
|
}
|
|
|
|
debug, err := strconv.ParseBool(viper.GetString("DEBUG"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DEBUG %q: %w",
|
|
viper.GetString("DEBUG"), errNotBool)
|
|
}
|
|
|
|
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an
|
|
// empty environment variable as unset and would give the default,
|
|
// so the environment is read first.
|
|
trustedProxies, set := os.LookupEnv("TRUSTED_PROXIES")
|
|
if !set {
|
|
trustedProxies = viper.GetString("TRUSTED_PROXIES")
|
|
}
|
|
|
|
s := &Config{
|
|
BindAddress: viper.GetString("BIND_ADDRESS"),
|
|
CORSAllowedOrigins: splitList(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
|
DataDir: viper.GetString("DATA_DIR"),
|
|
DataDirMaxBytes: viper.GetInt64("DATA_DIR_MAX_BYTES"),
|
|
Debug: debug,
|
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
Port: port,
|
|
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
|
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
TrustedProxies: splitList(trustedProxies),
|
|
log: log,
|
|
params: ¶ms,
|
|
}
|
|
|
|
err = s.check()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if s.Debug {
|
|
params.Logger.EnableDebugLogging()
|
|
s.log = params.Logger.Get()
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// check fails with an error naming the first setting here whose value
|
|
// the server cannot use. New checks PORT and DEBUG as it reads them,
|
|
// and the middleware checks TRUSTED_PROXIES as it parses it.
|
|
func (s *Config) check() error {
|
|
// viper reads a value that is not a number as 0, so this also
|
|
// catches a mistyped setting.
|
|
if s.ReportsPerMinute <= 0 {
|
|
return fmt.Errorf("REPORTS_PER_MINUTE %q: %w",
|
|
viper.GetString("REPORTS_PER_MINUTE"), errNotPositive)
|
|
}
|
|
|
|
if s.DataDirMaxBytes <= 0 {
|
|
return fmt.Errorf("DATA_DIR_MAX_BYTES %q: %w",
|
|
viper.GetString("DATA_DIR_MAX_BYTES"), errNotPositive)
|
|
}
|
|
|
|
if s.BindAddress != "" {
|
|
_, err := netip.ParseAddr(s.BindAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("BIND_ADDRESS %q: %w", s.BindAddress, errNotIP)
|
|
}
|
|
}
|
|
|
|
// The server records and serves metrics only with both set, so
|
|
// one alone is a mistake that would otherwise go unnoticed.
|
|
if (s.MetricsUsername == "") != (s.MetricsPassword == "") {
|
|
return errMetricsCredentials
|
|
}
|
|
|
|
// Basic auth splits the credentials at the first ":", so with one
|
|
// in the user name every request to /metrics would get 401.
|
|
if strings.Contains(s.MetricsUsername, ":") {
|
|
return errMetricsUsernameColon
|
|
}
|
|
|
|
return checkOrigins(s.CORSAllowedOrigins)
|
|
}
|
|
|
|
// checkOrigins fails on the first CORS_ALLOWED_ORIGINS entry that is
|
|
// not a plain origin, scheme://host with an optional port, as browsers
|
|
// send it; anything more, such as a trailing "/", would match no page.
|
|
// go-chi/cors reads a "*" anywhere in an entry as a wildcard, so no
|
|
// entry may contain one.
|
|
func checkOrigins(origins []string) error {
|
|
for _, origin := range origins {
|
|
u, err := url.Parse(origin)
|
|
if err != nil || u.Scheme == "" || u.Host == "" ||
|
|
strings.Contains(origin, "*") ||
|
|
origin != u.Scheme+"://"+u.Host {
|
|
return fmt.Errorf("CORS_ALLOWED_ORIGINS %q: %w",
|
|
origin, errNotOrigin)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// splitList turns a comma-separated setting into a trimmed
|
|
// slice, dropping empty entries.
|
|
func splitList(raw string) []string {
|
|
parts := strings.Split(raw, ",")
|
|
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|