Files
chat/internal/config/config.go
user e519ffa1e6 feat: add per-IP rate limiting to login endpoint
Add a token-bucket rate limiter (golang.org/x/time/rate) that limits
login attempts per client IP on POST /api/v1/login. Returns 429 Too
Many Requests with a Retry-After header when the limit is exceeded.

Configurable via LOGIN_RATE_LIMIT (requests/sec, default 1) and
LOGIN_RATE_BURST (burst size, default 5). Stale per-IP entries are
automatically cleaned up every 10 minutes.

Only the login endpoint is rate-limited per sneak's instruction —
session creation and registration use hashcash proof-of-work instead.
2026-03-17 04:48:46 -07:00

124 lines
3.6 KiB
Go

// Package config provides application configuration via environment and files.
package config
import (
"errors"
"log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper"
"go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // loads .env file
)
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// 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
LoginRateLimit float64
LoginRateBurst int
params *Params
log *slog.Logger
}
// New creates a new Config by reading from files and environment variables.
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", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
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("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5")
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"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
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"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
log: log,
params: &params,
}
if cfg.Debug {
params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get()
}
return cfg, nil
}