// 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/cast" "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") var errListOrMap = errors.New("a list or a map") // 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 the config file does not parse, when the file // gives a setting a list or a map, and 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) { return nil, fmt.Errorf("cannot read %s: %w", viper.ConfigFileUsed(), err) } } cfg := &Config{ log: log, params: ¶ms, } err = parseSettings(cfg) if err != nil { return nil, err } if cfg.Debug { params.Logger.EnableDebugLogging() cfg.log = params.Logger.Get() } return cfg, nil } // parseSettings reads every setting into cfg. 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 is a list or a map, does not parse or // is out of range. func parseSettings(cfg *Config) error { return errors.Join( parseString("DBURL", &cfg.DBURL), parseString("SENTRY_DSN", &cfg.SentryDSN), parseString("METRICS_USERNAME", &cfg.MetricsUsername), parseString("METRICS_PASSWORD", &cfg.MetricsPassword), parseString("MOTD", &cfg.MOTD), parseString("SERVER_NAME", &cfg.ServerName), parseString("FEDERATION_KEY", &cfg.FederationKey), parseString("NEOIRC_OPER_NAME", &cfg.OperName), parseString("NEOIRC_OPER_PASSWORD", &cfg.OperPassword), // The empty string turns the IRC listener off. parseStringAllowEmptyEnv("IRC_LISTEN_ADDR", &cfg.IRCListenAddr), 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), parseDuration("MESSAGE_MAX_AGE", &cfg.MessageMaxAge), parseDuration("QUEUE_MAX_AGE", &cfg.QueueMaxAge), parseDuration("SESSION_IDLE_TIMEOUT", &cfg.SessionIdleTimeout), parseTrustedProxies(&cfg.TrustedProxies), ) } // getString returns the setting key as a string. A number or a true/false // value in the config file becomes its text, as with viper.GetString. A // list or a map in the config file is an error, where viper.GetString // would return the empty string. The error leaves out the value, which // may be a password. func getString(key string) (string, error) { raw, err := cast.ToStringE(viper.Get(key)) if err != nil { return "", fmt.Errorf("%s is %w", key, errListOrMap) } return raw, nil } // parseString sets *value to the setting key. func parseString(key string, value *string) error { raw, err := getString(key) if err != nil { return err } *value = raw return nil } // parseStringAllowEmptyEnv sets *value to the setting key, as parseString // does, but an environment variable set to the empty string counts as // set. Viper counts such a variable as unset and uses the config file or // the default instead. func parseStringAllowEmptyEnv(key string, value *string) error { fromEnv, set := os.LookupEnv(key) if set { *value = fromEnv return nil } return parseString(key, value) } // parseTrustedProxies sets *value to the addresses in TRUSTED_PROXIES. func parseTrustedProxies(value **netipx.IPSet) error { var raw string err := parseStringAllowEmptyEnv("TRUSTED_PROXIES", &raw) if err != nil { return err } ipSet, err := ParseTrustedProxies(raw) if err != nil { return fmt.Errorf("cannot parse TRUSTED_PROXIES: %w", err) } *value = ipSet return nil } // ParseTrustedProxies returns the addresses in list, which has the form // of 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. func ParseTrustedProxies(list string) (*netipx.IPSet, error) { var builder netipx.IPSetBuilder if strings.TrimSpace(list) != "" { for entry := range strings.SplitSeq(list, ",") { err := addTrustedProxy(&builder, strings.TrimSpace(entry)) if err != nil { return nil, err } } } return builder.IPSet() } // 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, err := getString(key) if err != nil { return err } 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, err := getString(key) if err != nil { return err } 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 { raw, err := getString(key) if err != nil { return err } parsed, err := strconv.ParseBool(raw) if err != nil { return fmt.Errorf("cannot parse %s: %w", key, err) } *value = parsed return nil } // parseDuration sets *value to the setting key. It returns an error when // the value is not a Go duration above zero. func parseDuration(key string, value *string) error { raw, err := getString(key) if err != nil { return err } duration, err := time.ParseDuration(raw) 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, raw) } *value = raw return nil }