370 lines
12 KiB
Go
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
|
|
}
|