// Package config provides application configuration via environment and files. package config import ( "errors" "fmt" "log/slog" "math" "os" "strconv" "time" "github.com/spf13/viper" "go.uber.org/fx" "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)" var errOutOfRange = errors.New("setting out of range") // 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 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 or duration 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") 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 and true/false 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), ) } // 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 decimal 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 }