Files

370 lines
12 KiB
Go

// Package config loads and validates the migadu_counters TOML configuration,
// shared by the daemon (cmd/migadu_counters) and the CLI (cmd/counterctl).
package config
import (
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// DefaultPaths are tried in order when no -config flag is given.
var DefaultPaths = []string{
"/etc/migadu_counters/config.toml",
"/usr/local/etc/migadu_counters/config.toml",
}
// Environment variables that override the secrets from the file.
const (
EnvAuthToken = "MIGADU_COUNTERS_AUTH_TOKEN"
EnvRedisPassword = "MIGADU_COUNTERS_REDIS_PASSWORD"
EnvNotifyPassword = "MIGADU_COUNTERS_NOTIFY_PASSWORD"
)
// NotifyPlaceholders are the template variables every notify URL is expected
// to carry: the web app's /api/limits action pattern-matches all six query
// parameters and answers 500 when one is missing.
var NotifyPlaceholders = []string{"$LEVEL", "$ID", "$FLOW", "$PERIOD", "$ADDRESS", "$COUNT"}
// Config is the full daemon configuration.
type Config struct {
Addr string `toml:"addr"`
AdminAddr string `toml:"admin_addr"`
AuthToken string `toml:"auth_token"`
AllowedCIDRs []string `toml:"allowed_cidrs"`
DomainsCDB string `toml:"domains_cdb"`
Exempt []string `toml:"exempt"`
CountJunk bool `toml:"count_junk"`
// Counting runs off the request path: /in and /out enqueue a job and
// answer 202; Workers goroutines drain a queue of QueueSize jobs.
QueueSize int `toml:"queue_size"`
Workers int `toml:"workers"`
// Parsed CDB records are cached per loaded table up to this many
// domains; beyond it lookups parse without caching. 0 disables the cache.
MaxCachedDomains int `toml:"max_cached_domains"`
// HTTP server timeouts in seconds.
ReadHeaderTimeout int `toml:"read_header_timeout"`
WriteTimeout int `toml:"write_timeout"`
IdleTimeout int `toml:"idle_timeout"`
HandlerTimeout int `toml:"handler_timeout"`
Logging LoggingConfig `toml:"logging"`
Redis RedisConfig `toml:"redis"`
Notify NotifyConfig `toml:"notify"`
prefixes []netip.Prefix
exempt map[string]bool
}
// LoggingConfig holds the application log and the counting transaction log
// settings (the first three fields match the rcptd/mailqueuer/mizu pattern).
type LoggingConfig struct {
Output string `toml:"output"` // "stderr", "stdout", or a file path
Format string `toml:"format"` // "console" or "json"
Level string `toml:"level"` // "debug", "info", "warn", "error"
CountingOutput string `toml:"counting_output"` // default: same as Output; "off" disables
CountingFormat string `toml:"counting_format"` // "text" (Lua-like lines) or "json"
}
// CountingEnabled reports whether the counting transaction log is written.
func (l LoggingConfig) CountingEnabled() bool { return l.CountingOutput != "off" }
// RedisConfig describes the write (master) and read (replica) sides.
// Sentinel mode is used when Sentinels is non-empty; otherwise the static
// WriteAddr/ReadAddrs are used (development, tests).
type RedisConfig struct {
DB int `toml:"db"`
Password string `toml:"password"`
PoolSize int `toml:"pool_size"`
TimeoutMS int `toml:"timeout_ms"`
MasterName string `toml:"master_name"`
Sentinels []string `toml:"sentinels"`
SentinelPassword string `toml:"sentinel_password"`
WriteAddr string `toml:"write_addr"`
ReadAddrs []string `toml:"read_addrs"`
}
// SentinelMode reports whether addresses are discovered through Sentinel.
func (r RedisConfig) SentinelMode() bool { return len(r.Sentinels) > 0 }
// Timeout is the dial/read/write timeout for every Redis command.
func (r RedisConfig) Timeout() time.Duration { return time.Duration(r.TimeoutMS) * time.Millisecond }
// NotifyConfig holds the per-level web app notification URLs. An empty URL
// disables notifications for that level.
type NotifyConfig struct {
OrgURL string `toml:"org_url"`
AddressURL string `toml:"address_url"`
DomainURL string `toml:"domain_url"`
User string `toml:"user"`
Password string `toml:"password"`
TimeoutSeconds int `toml:"timeout_seconds"`
}
// Timeout is the per-attempt HTTP timeout for a notification.
func (n NotifyConfig) Timeout() time.Duration { return time.Duration(n.TimeoutSeconds) * time.Second }
// Default returns the configuration with every default applied.
func Default() Config {
return Config{
Addr: ":5555",
AdminAddr: ":9095",
Exempt: []string{"support@migadu.com", "contact@migadu.com"},
QueueSize: 10000,
Workers: 8,
MaxCachedDomains: 20000,
ReadHeaderTimeout: 5,
WriteTimeout: 30,
IdleTimeout: 60,
HandlerTimeout: 4,
Logging: LoggingConfig{Output: "stderr", Format: "console", Level: "info", CountingFormat: "text"},
Redis: RedisConfig{PoolSize: 32, TimeoutMS: 500},
Notify: NotifyConfig{TimeoutSeconds: 5},
}
}
// Discover returns flagPath when given, otherwise the first existing entry of
// DefaultPaths, or an error naming every path that was tried.
func Discover(flagPath string) (string, error) {
if flagPath != "" {
return flagPath, nil
}
for _, p := range DefaultPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no config found at %s; specify -config PATH", strings.Join(DefaultPaths, " or "))
}
// Load reads the TOML file at path, applies defaults, environment overrides
// and normalisation, and validates the result. Unknown keys are fatal: a typo
// such as "auth-token" must not start the daemon with the default in place.
func Load(path string) (*Config, error) {
cfg := Default()
md, err := toml.DecodeFile(path, &cfg)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var unknown []error
for _, k := range md.Undecoded() {
unknown = append(unknown, fmt.Errorf("unknown config key %s", k.String()))
}
if len(unknown) > 0 {
return nil, fmt.Errorf("config %s: %w", path, errors.Join(unknown...))
}
if v := os.Getenv(EnvAuthToken); v != "" {
cfg.AuthToken = v
}
if v := os.Getenv(EnvRedisPassword); v != "" {
cfg.Redis.Password = v
}
if v := os.Getenv(EnvNotifyPassword); v != "" {
cfg.Notify.Password = v
}
// A relative CDB path is resolved against the config file's directory,
// like rcptd's cdb_tables_dir.
if cfg.DomainsCDB != "" && !filepath.IsAbs(cfg.DomainsCDB) {
cfg.DomainsCDB = filepath.Join(filepath.Dir(path), cfg.DomainsCDB)
}
cfg.normalize()
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
return &cfg, nil
}
func (c *Config) normalize() {
c.exempt = make(map[string]bool, len(c.Exempt))
for _, a := range c.Exempt {
if a = strings.ToLower(strings.TrimSpace(a)); a != "" {
c.exempt[a] = true
}
}
c.Logging.Format = strings.ToLower(strings.TrimSpace(c.Logging.Format))
c.Logging.Level = strings.ToLower(strings.TrimSpace(c.Logging.Level))
c.Logging.CountingFormat = strings.ToLower(strings.TrimSpace(c.Logging.CountingFormat))
c.Logging.CountingOutput = strings.TrimSpace(c.Logging.CountingOutput)
switch {
case c.Logging.CountingOutput == "":
c.Logging.CountingOutput = c.Logging.Output
case strings.EqualFold(c.Logging.CountingOutput, "off"):
c.Logging.CountingOutput = "off"
}
if !c.Redis.SentinelMode() && len(c.Redis.ReadAddrs) == 0 && c.Redis.WriteAddr != "" {
c.Redis.ReadAddrs = []string{c.Redis.WriteAddr}
}
}
// Validate checks the configuration and returns every problem found, joined.
func (c *Config) Validate() error {
var errs []error
fail := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if c.DomainsCDB == "" {
fail("domains_cdb is required")
}
if strings.TrimSpace(c.AuthToken) == "" {
fail("auth_token is required; set it in the config or %s", EnvAuthToken)
}
if c.QueueSize <= 0 {
fail("queue_size must be > 0")
}
if c.Workers <= 0 {
fail("workers must be > 0")
}
if c.MaxCachedDomains < 0 {
fail("max_cached_domains must be >= 0")
}
for _, a := range []struct{ name, addr string }{{"addr", c.Addr}, {"admin_addr", c.AdminAddr}} {
if _, _, err := net.SplitHostPort(a.addr); err != nil {
fail("%s: invalid listen address %q: %v", a.name, a.addr, err)
}
}
for _, d := range []struct {
name string
v int
}{{"read_header_timeout", c.ReadHeaderTimeout}, {"write_timeout", c.WriteTimeout}, {"idle_timeout", c.IdleTimeout}, {"handler_timeout", c.HandlerTimeout}} {
if d.v <= 0 {
fail("%s must be > 0 (seconds)", d.name)
}
}
c.prefixes = c.prefixes[:0]
for _, s := range c.AllowedCIDRs {
p, err := parsePrefix(s)
if err != nil {
fail("allowed_cidrs: invalid entry %q: %v", s, err)
continue
}
c.prefixes = append(c.prefixes, p)
}
switch c.Logging.Format {
case "console", "json":
default:
fail("logging.format: %q (must be console or json)", c.Logging.Format)
}
if _, err := parseLogLevel(c.Logging.Level); err != nil {
fail("logging.level: %v", err)
}
switch c.Logging.CountingFormat {
case "text", "json":
default:
fail("logging.counting_format: %q (must be text or json)", c.Logging.CountingFormat)
}
if c.Redis.DB != 0 {
fail("redis.db must be 0: migadu_web reads the message counters from DB 0 (got %d)", c.Redis.DB)
}
if c.Redis.PoolSize <= 0 {
fail("redis.pool_size must be > 0")
}
if c.Redis.TimeoutMS <= 0 {
fail("redis.timeout_ms must be > 0")
}
if c.Redis.SentinelMode() {
if c.Redis.MasterName == "" {
fail("redis.master_name is required when redis.sentinels is set")
}
for _, s := range c.Redis.Sentinels {
if _, _, err := net.SplitHostPort(s); err != nil {
fail("redis.sentinels: invalid address %q: %v", s, err)
}
}
} else {
if c.Redis.WriteAddr == "" {
fail("redis.write_addr is required (or configure redis.sentinels)")
} else if _, _, err := net.SplitHostPort(c.Redis.WriteAddr); err != nil {
fail("redis.write_addr: invalid address %q: %v", c.Redis.WriteAddr, err)
}
for _, s := range c.Redis.ReadAddrs {
if _, _, err := net.SplitHostPort(s); err != nil {
fail("redis.read_addrs: invalid address %q: %v", s, err)
}
}
}
for _, u := range c.notifyURLs() {
if u.url == "" {
continue
}
parsed, err := url.Parse(u.url)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
fail("notify.%s: must be an absolute http(s) URL (got %q)", u.name, u.url)
}
}
if c.Notify.TimeoutSeconds <= 0 {
fail("notify.timeout_seconds must be > 0")
}
return errors.Join(errs...)
}
// Warnings returns non-fatal findings worth logging at startup.
func (c *Config) Warnings() []string {
var w []string
for _, u := range c.notifyURLs() {
if u.url == "" {
continue
}
for _, ph := range NotifyPlaceholders {
if !strings.Contains(u.url, ph) {
w = append(w, fmt.Sprintf("notify.%s: missing %s placeholder (the web app requires all six query parameters)", u.name, ph))
}
}
}
return w
}
func (c *Config) notifyURLs() []struct{ name, url string } {
return []struct{ name, url string }{
{"org_url", c.Notify.OrgURL},
{"address_url", c.Notify.AddressURL},
{"domain_url", c.Notify.DomainURL},
}
}
// ExemptSet returns the normalised exempt addresses.
func (c *Config) ExemptSet() map[string]bool { return c.exempt }
// AllowedPrefixes returns the parsed allowed_cidrs; empty means any source.
func (c *Config) AllowedPrefixes() []netip.Prefix { return c.prefixes }
// HandlerTimeoutDuration is the http.TimeoutHandler budget.
func (c *Config) HandlerTimeoutDuration() time.Duration {
return time.Duration(c.HandlerTimeout) * time.Second
}
// parsePrefix accepts "10.0.0.0/8" as well as a bare address ("192.168.1.5",
// "::1"), which becomes a single-host prefix.
func parsePrefix(s string) (netip.Prefix, error) {
s = strings.TrimSpace(s)
if p, err := netip.ParsePrefix(s); err == nil {
return p.Masked(), nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, errors.New("not a CIDR prefix or IP address")
}
return netip.PrefixFrom(a, a.BitLen()), nil
}