// 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 }