// Package config provides application configuration via environment and files. package config import ( "errors" "fmt" "log/slog" "math" "net/netip" "os" "strconv" "strings" "time" "github.com/spf13/viper" "go.uber.org/fx" "go4.org/netipx" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" _ "github.com/joho/godotenv/autoload" // loads .env file ) const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___ | '_ \ / _ \/ _ \ | | '__/ __| | | | | __/ (_) || | | | (__ |_| |_|\___|\___/ |_|_| \___| Welcome to NeoIRC — IRC semantics over HTTP. Type /help for available commands.` // DefaultDBURL is the database URL used when DBURL is not set. Its // _pragma parameters, which the SQLite driver runs on every connection // it opens, set WAL mode and a 5000 ms busy timeout. const DefaultDBURL = "file:///var/lib/neoirc/state.db" + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" // defaultTrustedProxies is TRUSTED_PROXIES when it is not set: the // private address ranges of RFC 1918. const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" var errOutOfRange = errors.New("setting out of range") var errNeverMatches = errors.New("entry can never match a connection") // Params defines the dependencies for creating a Config. type Params struct { fx.In Globals *globals.Globals Logger *logger.Logger } // Config holds all application configuration values. type Config struct { DBURL string Debug bool MaintenanceMode bool MetricsPassword string MetricsUsername string Port int SentryDSN string MessageMaxAge string MaxMessageSize int QueueMaxAge string MOTD string ServerName string FederationKey string SessionIdleTimeout string HashcashBits int OperName string OperPassword string LoginRateLimit float64 LoginRateBurst int IRCListenAddr string TrustedProxies *netipx.IPSet params *Params log *slog.Logger } // New creates a new Config by reading from files and environment variables. // It returns an error when a number, true/false, duration or address // setting does not parse or is out of range. 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() viper.SetDefault("DEBUG", "false") viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("PORT", "8080") viper.SetDefault("DBURL", DefaultDBURL) viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("QUEUE_MAX_AGE", "720h") viper.SetDefault("MOTD", defaultMOTD) viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") viper.SetDefault("NEOIRC_HASHCASH_BITS", "20") viper.SetDefault("NEOIRC_OPER_NAME", "") viper.SetDefault("NEOIRC_OPER_PASSWORD", "") viper.SetDefault("LOGIN_RATE_LIMIT", "1") viper.SetDefault("LOGIN_RATE_BURST", "5") viper.SetDefault("IRC_LISTEN_ADDR", ":6667") 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) } } cfg := &Config{ DBURL: viper.GetString("DBURL"), SentryDSN: viper.GetString("SENTRY_DSN"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), OperName: viper.GetString("NEOIRC_OPER_NAME"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), log: log, params: ¶ms, } err = parseSettings(cfg) if err != nil { return nil, err } // Viper counts an empty environment variable as unset, but an // empty IRC_LISTEN_ADDR must turn the IRC listener off. ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") if set { cfg.IRCListenAddr = ircListenAddr } if cfg.Debug { params.Logger.EnableDebugLogging() cfg.log = params.Logger.Get() } return cfg, nil } // parseSettings reads the number, true/false and address settings into // cfg and checks the duration settings. Each number and duration must be // above zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets // the system pick a port, and 0 bits turns proof of work off. The error // names each setting that does not parse or is out of range. func parseSettings(cfg *Config) error { return errors.Join( parseInt("PORT", 0, &cfg.Port), parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize), parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits), parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst), parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit), parseBool("DEBUG", &cfg.Debug), parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode), checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge), checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge), checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout), parseTrustedProxies(&cfg.TrustedProxies), ) } // parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES, a // comma-separated list of networks such as 10.0.0.0/8 and single // addresses such as 192.0.2.1. The empty string trusts nothing. Viper // counts an empty environment variable as unset, so the environment is // read directly as well. func parseTrustedProxies(value **netipx.IPSet) error { raw := viper.GetString("TRUSTED_PROXIES") fromEnv, set := os.LookupEnv("TRUSTED_PROXIES") if set { raw = fromEnv } var builder netipx.IPSetBuilder if strings.TrimSpace(raw) != "" { for entry := range strings.SplitSeq(raw, ",") { err := addTrustedProxy(&builder, strings.TrimSpace(entry)) if err != nil { return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err) } } } ipSet, err := builder.IPSet() if err != nil { return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err) } *value = ipSet return nil } // addTrustedProxy adds entry, a network with a slash or a single // address, to builder. An IPv4 address written in IPv6 form, such as // ::ffff:192.0.2.1, is added as IPv4. A network in that form and an // address with a zone are errors, since no connection could match them. func addTrustedProxy(builder *netipx.IPSetBuilder, entry string) error { if strings.Contains(entry, "/") { prefix, err := netip.ParsePrefix(entry) if err != nil { return err //nolint:wrapcheck // the caller names the setting } // A connection from an IPv4 address is checked in IPv4 form. if prefix.Addr().Is4In6() { return fmt.Errorf("%w: %s is an IPv4 network in IPv6 form", errNeverMatches, entry) } builder.AddPrefix(prefix) return nil } addr, err := netip.ParseAddr(entry) if err != nil { return err //nolint:wrapcheck // the caller names the setting } // IPSet.Contains is false for every address with a zone. if addr.Zone() != "" { return fmt.Errorf("%w: %s has a zone", errNeverMatches, entry) } builder.Add(addr.Unmap()) return nil } // parseInt sets *value to the setting key read as a whole number. It // returns an error when the value does not parse or is below minimum. func parseInt(key string, minimum int, value *int) error { raw := viper.GetString(key) parsed, err := strconv.Atoi(raw) if err != nil { return fmt.Errorf("cannot parse %s: %w", key, err) } if parsed < minimum { return fmt.Errorf("%w: %s is %s, but must be at least %d", errOutOfRange, key, raw, minimum) } *value = parsed return nil } // parseFloat sets *value to the setting key read as a floating-point // number. It returns an error when the value does not parse or is not a // finite number above zero. func parseFloat(key string, value *float64) error { raw := viper.GetString(key) parsed, err := strconv.ParseFloat(raw, 64) if err != nil { return fmt.Errorf("cannot parse %s: %w", key, err) } if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 { return fmt.Errorf( "%w: %s is %s, but must be a finite number above zero", errOutOfRange, key, raw) } *value = parsed return nil } // parseBool sets *value to the setting key read as true or false. func parseBool(key string, value *bool) error { parsed, err := strconv.ParseBool(viper.GetString(key)) if err != nil { return fmt.Errorf("cannot parse %s: %w", key, err) } *value = parsed return nil } // checkDuration returns an error naming the setting key when value is // not a Go duration above zero. func checkDuration(key, value string) error { duration, err := time.ParseDuration(value) if err != nil { return fmt.Errorf("cannot parse %s: %w", key, err) } if duration <= 0 { return fmt.Errorf("%w: %s is %s, but must be above zero", errOutOfRange, key, value) } return nil }