check / check (push) Failing after 3s
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
378 lines
10 KiB
Go
378 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 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, 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(value **netipx.IPSet) error {
|
|
var raw string
|
|
|
|
err := parseStringAllowEmptyEnv("TRUSTED_PROXIES", &raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|