// Package config loads application configuration from // environment variables, .env files, and config files. package config import ( "errors" "fmt" "log/slog" "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") // 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. 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) } } 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: viper.GetBool("DEBUG"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsUsername: viper.GetString("METRICS_USERNAME"), Port: viper.GetInt("PORT"), ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"), SentryDSN: viper.GetString("SENTRY_DSN"), TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")), log: log, params: ¶ms, } // viper reads a value that is not a number as 0, so this also // catches a mistyped setting. if s.ReportsPerMinute <= 0 { return nil, fmt.Errorf("REPORTS_PER_MINUTE %q: %w", viper.GetString("REPORTS_PER_MINUTE"), errNotPositive) } if s.DataDirMaxBytes <= 0 { return nil, fmt.Errorf("DATA_DIR_MAX_BYTES %q: %w", viper.GetString("DATA_DIR_MAX_BYTES"), errNotPositive) } if s.Debug { params.Logger.EnableDebugLogging() s.log = params.Logger.Get() } return s, 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 }