Files
neoirc/internal/config/config.go
T
sneak 39cbb34ccc
check / check (push) Waiting to run
Stop startup when a setting does not parse (closes #219)
config.New read the number and true/false settings with viper's
GetInt, GetFloat64 and GetBool. These return zero or false for a value
that does not parse, so a typo silently changed how the server ran.

config.New now parses these settings with strconv. It also checks that
MESSAGE_MAX_AGE, QUEUE_MAX_AGE and SESSION_IDLE_TIMEOUT are valid Go
durations. A value that does not parse makes config.New return an
error naming the setting and its value, and startup stops. Unset
settings keep their defaults.

Model: opus-5-5
2026-10-27 21:43:30 +00:00

215 lines
6.1 KiB
Go

// Package config provides application configuration via environment and files.
package config
import (
"errors"
"fmt"
"log/slog"
"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)"
// 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.
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, &notFound) {
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: &params,
}
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 that each duration setting is a valid Go duration. Its error
// names each setting whose value does not parse.
func parseSettings(cfg *Config) error {
return errors.Join(
parseInt("PORT", &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", &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.
func parseInt(key string, value *int) error {
parsed, err := strconv.Atoi(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*value = parsed
return nil
}
// parseFloat sets *value to the setting key read as a decimal number.
func parseFloat(key string, value *float64) error {
parsed, err := strconv.ParseFloat(viper.GetString(key), 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*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 valid Go duration.
func checkDuration(key, value string) error {
_, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
return nil
}