// 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" "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 by default. It covers the RFC1918 // ranges (to match nginx.conf) plus IPv4 and IPv6 loopback, // because the reverse proxy shares the container and reaches // the backend over loopback. const defaultTrustedProxies = "127.0.0.1/32,::1/128," + "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" // Default limits on stored reports; backend/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") ) // 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. A setting whose value // the server cannot use fails it, with an error naming the setting. 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) { log.Error("config file malformed", "error", err) panic(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) } 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(viper.GetString("TRUSTED_PROXIES")), 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) } } 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 }