Files
neoirc/internal/config/config.go
T
sneak dc19d0d7d1
check / check (push) Failing after 6s
Stop startup on a list or map setting in neoirc.yaml (closes #268)
Every setting is now read through one helper that uses cast.ToStringE,
the conversion viper.GetString wraps, keeping its error instead of
returning the empty string. TRUSTED_PROXIES uses the same helper. A
config file that does not parse makes New return an error naming the
file instead of panicking.

Model: opus-5-5
2026-11-10 21:54:09 +00:00

375 lines
10 KiB
Go

// 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 setting cannot be 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, &notFound) {
return nil, fmt.Errorf("cannot read %s: %w",
viper.ConfigFileUsed(), err)
}
}
cfg := &Config{
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 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),
parseString("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.
func getString(key string) (string, error) {
value := viper.Get(key)
raw, err := cast.ToStringE(value)
if err != nil {
return "", fmt.Errorf("%w: %s is %v", errListOrMap, key, value)
}
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
}
// 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, err := getString("TRUSTED_PROXIES")
if err != nil {
return err
}
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, 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
}