check / check (push) Waiting to run
The slowest test packages spent their time waiting rather than testing. db.Params gains an optional PasswordCost (zero means bcrypt.DefaultCost), and the db, ircserver and handlers tests hash at bcrypt.MinCost. A test IRC server's relay checks its queue on broker notification instead of polling. The service and handlers tests build config.Config directly and run in parallel; config exports DefaultTrustedProxies and ParseTrustedProxies for them. Fixed sleeps give way to waits for the event. Production defaults are unchanged. What still takes time is CPU work under -race or a timeout a test is about. Model: opus-5-5
384 lines
10 KiB
Go
384 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.
|
|
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
|
|
}
|