First commit Go counter service
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeConfig writes body to <tmpdir>/config.toml and returns its path.
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TOML: top-level keys must precede any [table], and a table may be defined
|
||||
// only once — fixtures are therefore assembled as top + ... + redisStatic.
|
||||
const (
|
||||
top = "domains_cdb = \"/tmp/rspamd_domains_db\"\nauth_token = \"t\"\n"
|
||||
redisStatic = "[redis]\nwrite_addr = \"127.0.0.1:6379\"\n"
|
||||
minimal = top + redisStatic
|
||||
)
|
||||
|
||||
func mustLoad(t *testing.T, body string) *Config {
|
||||
t.Helper()
|
||||
cfg, err := Load(writeConfig(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v\n--- config ---\n%s", err, body)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
cfg := mustLoad(t, minimal)
|
||||
checks := []struct {
|
||||
name string
|
||||
got any
|
||||
want any
|
||||
}{
|
||||
{"addr", cfg.Addr, ":5555"},
|
||||
{"admin_addr", cfg.AdminAddr, ":9095"},
|
||||
{"auth_token", cfg.AuthToken, "t"},
|
||||
{"queue_size", cfg.QueueSize, 10000},
|
||||
{"workers", cfg.Workers, 8},
|
||||
{"max_cached_domains", cfg.MaxCachedDomains, 20000},
|
||||
{"count_junk", cfg.CountJunk, false},
|
||||
{"read_header_timeout", cfg.ReadHeaderTimeout, 5},
|
||||
{"write_timeout", cfg.WriteTimeout, 30},
|
||||
{"idle_timeout", cfg.IdleTimeout, 60},
|
||||
{"handler_timeout", cfg.HandlerTimeout, 4},
|
||||
{"logging.output", cfg.Logging.Output, "stderr"},
|
||||
{"logging.format", cfg.Logging.Format, "console"},
|
||||
{"logging.level", cfg.Logging.Level, "info"},
|
||||
{"logging.counting_output", cfg.Logging.CountingOutput, "stderr"},
|
||||
{"logging.counting_format", cfg.Logging.CountingFormat, "text"},
|
||||
{"redis.db", cfg.Redis.DB, 0},
|
||||
{"redis.pool_size", cfg.Redis.PoolSize, 32},
|
||||
{"redis.timeout_ms", cfg.Redis.TimeoutMS, 500},
|
||||
{"redis.sentinel_mode", cfg.Redis.SentinelMode(), false},
|
||||
{"notify.timeout_seconds", cfg.Notify.TimeoutSeconds, 5},
|
||||
{"redis timeout", cfg.Redis.Timeout(), 500 * time.Millisecond},
|
||||
{"notify timeout", cfg.Notify.Timeout(), 5 * time.Second},
|
||||
{"handler timeout", cfg.HandlerTimeoutDuration(), 4 * time.Second},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if got := cfg.Redis.ReadAddrs; len(got) != 1 || got[0] != "127.0.0.1:6379" {
|
||||
t.Errorf("read_addrs = %v, want [write_addr]", got)
|
||||
}
|
||||
if got := cfg.ExemptSet(); len(got) != 2 || !got["support@migadu.com"] || !got["contact@migadu.com"] {
|
||||
t.Errorf("ExemptSet() = %v, want support@ and contact@", got)
|
||||
}
|
||||
if len(cfg.AllowedPrefixes()) != 0 {
|
||||
t.Errorf("AllowedPrefixes() = %v, want none", cfg.AllowedPrefixes())
|
||||
}
|
||||
if !cfg.Logging.CountingEnabled() {
|
||||
t.Error("counting log should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped example must always load, validate and produce no warnings.
|
||||
func TestLoad_Example(t *testing.T) {
|
||||
cfg, err := Load("../../config.toml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(config.toml.example): %v", err)
|
||||
}
|
||||
if !cfg.Redis.SentinelMode() || cfg.Redis.MasterName != "mymaster" || len(cfg.Redis.Sentinels) != 3 {
|
||||
t.Errorf("example should be in sentinel mode with 3 sentinels: %+v", cfg.Redis)
|
||||
}
|
||||
if cfg.Redis.DB != 0 {
|
||||
t.Errorf("example redis.db = %d, must be 0", cfg.Redis.DB)
|
||||
}
|
||||
if cfg.Notify.OrgURL == "" || cfg.Notify.AddressURL == "" {
|
||||
t.Error("example should set org_url and address_url")
|
||||
}
|
||||
if cfg.Notify.DomainURL != "" {
|
||||
t.Error("example should leave domain_url empty (web app D branch is a TODO)")
|
||||
}
|
||||
for _, w := range cfg.Warnings() {
|
||||
t.Errorf("example produced warning: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile(t *testing.T) {
|
||||
if _, err := Load(filepath.Join(t.TempDir(), "nope.toml")); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_BadTOML(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, "addr = [unterminated")); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_RelativeDomainsCDBAnchoredToConfigDir(t *testing.T) {
|
||||
path := writeConfig(t, "domains_cdb = \"db/rspamd_domains_db\"\nauth_token = \"t\"\n"+redisStatic)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(filepath.Dir(path), "db/rspamd_domains_db")
|
||||
if cfg.DomainsCDB != want {
|
||||
t.Errorf("domains_cdb = %q, want %q", cfg.DomainsCDB, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverrides(t *testing.T) {
|
||||
t.Setenv("MIGADU_COUNTERS_AUTH_TOKEN", "env-token")
|
||||
t.Setenv("MIGADU_COUNTERS_REDIS_PASSWORD", "env-redis")
|
||||
t.Setenv("MIGADU_COUNTERS_NOTIFY_PASSWORD", "env-notify")
|
||||
cfg := mustLoad(t, "domains_cdb = \"/tmp/x\"\nauth_token = \"file-token\"\n"+redisStatic+"password = \"file-redis\"\n[notify]\npassword = \"file-notify\"\n")
|
||||
if cfg.AuthToken != "env-token" || cfg.Redis.Password != "env-redis" || cfg.Notify.Password != "env-notify" {
|
||||
t.Errorf("env overrides not applied: %q %q %q", cfg.AuthToken, cfg.Redis.Password, cfg.Notify.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ExemptNormalized(t *testing.T) {
|
||||
cfg := mustLoad(t, top+"exempt = [\" Support@Migadu.com \", \"\", \"x@y.z\"]\n"+redisStatic)
|
||||
got := cfg.ExemptSet()
|
||||
if len(got) != 2 || !got["support@migadu.com"] || !got["x@y.z"] {
|
||||
t.Errorf("ExemptSet() = %v, want only the configured entries, normalised", got)
|
||||
}
|
||||
// An explicitly empty list disables the exemption entirely.
|
||||
if got := mustLoad(t, top+"exempt = []\n"+redisStatic).ExemptSet(); len(got) != 0 {
|
||||
t.Errorf("exempt = [] gave %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AllowedCIDRs(t *testing.T) {
|
||||
cfg := mustLoad(t, top+"allowed_cidrs = [\"10.0.0.0/8\", \"192.168.1.5\", \"::1\"]\n"+redisStatic)
|
||||
p := cfg.AllowedPrefixes()
|
||||
if len(p) != 3 || p[0].Bits() != 8 || p[1].Bits() != 32 || p[2].Bits() != 128 {
|
||||
t.Errorf("AllowedPrefixes() = %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SentinelMode(t *testing.T) {
|
||||
cfg := mustLoad(t, top+"[redis]\nmaster_name = \"mymaster\"\nsentinels = [\"10.0.0.1:26379\", \"10.0.0.2:26379\"]\n")
|
||||
if !cfg.Redis.SentinelMode() || len(cfg.Redis.ReadAddrs) != 0 || cfg.Redis.WriteAddr != "" {
|
||||
t.Errorf("sentinel mode config = %+v", cfg.Redis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, toml, wantErr string
|
||||
}{
|
||||
{"redis db must be 0", top + redisStatic + "db = 1\n", "redis.db"},
|
||||
{"domains_cdb required", "auth_token = \"t\"\n" + redisStatic, "domains_cdb"},
|
||||
{"auth_token required", "domains_cdb = \"/tmp/x\"\n" + redisStatic, "auth_token is required"},
|
||||
{"auth_token blank", "domains_cdb = \"/tmp/x\"\nauth_token = \" \"\n" + redisStatic, "auth_token is required"},
|
||||
{"typo auth-token", "domains_cdb = \"/tmp/x\"\nauth-token = \"s3cret\"\n" + redisStatic, "unknown config key auth-token"},
|
||||
{"typo allowed_cidr", top + "allowed_cidr = [\"10.0.0.0/8\"]\n" + redisStatic, "unknown config key allowed_cidr"},
|
||||
{"typo in table", top + redisStatic + "pool = 3\n", "unknown config key redis.pool"},
|
||||
{"queue_size", top + "queue_size = 0\n" + redisStatic, "queue_size"},
|
||||
{"workers", top + "workers = 0\n" + redisStatic, "workers"},
|
||||
{"max_cached_domains", top + "max_cached_domains = -1\n" + redisStatic, "max_cached_domains"},
|
||||
{"sentinel needs master_name", top + "[redis]\nsentinels = [\"10.0.0.1:26379\"]\n", "master_name"},
|
||||
{"sentinel bad address", top + "[redis]\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1\"]\n", "sentinels"},
|
||||
{"static needs write_addr", top, "write_addr"},
|
||||
{"static bad read_addr", top + redisStatic + "read_addrs = [\"nohost\"]\n", "read_addrs"},
|
||||
{"bad cidr mask", top + "allowed_cidrs = [\"10.0.0.0/33\"]\n" + redisStatic, "allowed_cidrs"},
|
||||
{"bad cidr text", top + "allowed_cidrs = [\"nonsense\"]\n" + redisStatic, "allowed_cidrs"},
|
||||
{"notify scheme", minimal + "[notify]\norg_url = \"ftp://x/limits\"\n", "org_url"},
|
||||
{"notify no scheme", minimal + "[notify]\naddress_url = \"not a url\"\n", "address_url"},
|
||||
{"notify no host", minimal + "[notify]\ndomain_url = \"https:///path\"\n", "domain_url"},
|
||||
{"logging format", minimal + "[logging]\nformat = \"xml\"\n", "logging.format"},
|
||||
{"logging level", minimal + "[logging]\nlevel = \"loud\"\n", "logging.level"},
|
||||
{"counting format", minimal + "[logging]\ncounting_format = \"csv\"\n", "counting_format"},
|
||||
{"pool size", top + redisStatic + "pool_size = -1\n", "pool_size"},
|
||||
{"timeout_ms", top + redisStatic + "timeout_ms = 0\n", "timeout_ms"},
|
||||
{"handler_timeout", top + "handler_timeout = 0\n" + redisStatic, "handler_timeout"},
|
||||
{"addr", top + "addr = \"no-port\"\n" + redisStatic, "addr"},
|
||||
{"admin_addr", top + "admin_addr = \"no-port\"\n" + redisStatic, "admin_addr"},
|
||||
{"notify timeout", minimal + "[notify]\ntimeout_seconds = 0\n", "timeout_seconds"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Load(writeConfig(t, tt.toml))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error mentioning %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error %q does not mention %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_CountingOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
toml string
|
||||
wantOut string
|
||||
wantEnabled bool
|
||||
}{
|
||||
{"", "stderr", true},
|
||||
{"[logging]\noutput = \"/var/log/a.log\"\n", "/var/log/a.log", true}, // follows output
|
||||
{"[logging]\noutput = \"/var/log/a.log\"\ncounting_output = \"/var/log/c.log\"\n", "/var/log/c.log", true},
|
||||
{"[logging]\ncounting_output = \"off\"\n", "off", false},
|
||||
{"[logging]\ncounting_output = \"OFF\"\n", "off", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
cfg := mustLoad(t, minimal+tt.toml)
|
||||
if cfg.Logging.CountingOutput != tt.wantOut || cfg.Logging.CountingEnabled() != tt.wantEnabled {
|
||||
t.Errorf("%q: counting_output=%q enabled=%v, want %q %v", tt.toml,
|
||||
cfg.Logging.CountingOutput, cfg.Logging.CountingEnabled(), tt.wantOut, tt.wantEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnings_MissingPlaceholders(t *testing.T) {
|
||||
cfg := mustLoad(t, top+redisStatic+`
|
||||
[notify]
|
||||
org_url = "https://admin.example/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS"
|
||||
address_url = "https://admin.example/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
|
||||
`)
|
||||
w := cfg.Warnings()
|
||||
if len(w) != 1 || !strings.Contains(w[0], "org_url") || !strings.Contains(w[0], "$COUNT") {
|
||||
t.Errorf("Warnings() = %v, want exactly one about org_url missing $COUNT", w)
|
||||
}
|
||||
}
|
||||
|
||||
// The env var satisfies the mandatory token like the file does; a typo in the
|
||||
// key never starts the daemon unauthenticated.
|
||||
func TestLoad_TokenFromEnvOnly(t *testing.T) {
|
||||
t.Setenv("MIGADU_COUNTERS_AUTH_TOKEN", "env-token")
|
||||
cfg := mustLoad(t, "domains_cdb = \"/tmp/x\"\n"+redisStatic)
|
||||
if cfg.AuthToken != "env-token" {
|
||||
t.Errorf("auth_token = %q", cfg.AuthToken)
|
||||
}
|
||||
if _, err := Load(writeConfig(t, "domains_cdb = \"/tmp/x\"\nauth-token = \"x\"\n"+redisStatic)); err == nil {
|
||||
t.Error("typo key must be fatal even when the env var supplies a token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_QueueAndCache(t *testing.T) {
|
||||
cfg := mustLoad(t, top+"queue_size = 50\nworkers = 2\nmax_cached_domains = 0\n"+redisStatic)
|
||||
if cfg.QueueSize != 50 || cfg.Workers != 2 || cfg.MaxCachedDomains != 0 {
|
||||
t.Errorf("queue/cache = %d %d %d", cfg.QueueSize, cfg.Workers, cfg.MaxCachedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover(t *testing.T) {
|
||||
if got, err := Discover("/explicit/path.toml"); err != nil || got != "/explicit/path.toml" {
|
||||
t.Errorf("Discover(explicit) = %q, %v", got, err)
|
||||
}
|
||||
// With no default file present on the test machine, Discover must explain where it looked.
|
||||
paths := DefaultPaths
|
||||
DefaultPaths = []string{filepath.Join(t.TempDir(), "missing.toml")}
|
||||
defer func() { DefaultPaths = paths }()
|
||||
if _, err := Discover(""); err == nil || !strings.Contains(err.Error(), "missing.toml") {
|
||||
t.Errorf("Discover(\"\") err = %v, want one listing the searched paths", err)
|
||||
}
|
||||
found := writeConfig(t, minimal)
|
||||
DefaultPaths = []string{DefaultPaths[0], found}
|
||||
if got, err := Discover(""); err != nil || got != found {
|
||||
t.Errorf("Discover(\"\") = %q, %v; want %q", got, err, found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogWriter wraps a log destination and supports reopening for log rotation.
|
||||
// When the output is a file, Reopen closes the old handle and opens a new one
|
||||
// so that newsyslog rotation works after SIGHUP. Shared by the application log
|
||||
// and the counting transaction log. (Same implementation as rcptd.)
|
||||
type LogWriter struct {
|
||||
mu sync.Mutex
|
||||
path string // empty for stdout/stderr
|
||||
file *os.File // current output file (or os.Stdout/os.Stderr)
|
||||
}
|
||||
|
||||
func (w *LogWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.file.Write(p)
|
||||
}
|
||||
|
||||
// Reopen closes and reopens the log file. No-op for stdout/stderr.
|
||||
func (w *LogWriter) Reopen() error {
|
||||
if w.path == "" {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
newFile, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen log file %s: %w", w.path, err)
|
||||
}
|
||||
w.file.Close()
|
||||
w.file = newFile
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the log file. No-op for stdout/stderr.
|
||||
func (w *LogWriter) Close() error {
|
||||
if w.path == "" {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.file.Close()
|
||||
}
|
||||
|
||||
// NewWriter opens a log destination: "stderr", "stdout" or a file path.
|
||||
func NewWriter(output string) (*LogWriter, error) { return newLogWriter(output) }
|
||||
|
||||
// NewLogger creates the application slog.Logger from cfg and installs it as
|
||||
// the slog default. The returned LogWriter must be closed by the caller.
|
||||
func NewLogger(cfg LoggingConfig) (*slog.Logger, *LogWriter, error) {
|
||||
level, err := parseLogLevel(cfg.Level)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid log level: %w", err)
|
||||
}
|
||||
|
||||
writer, err := newLogWriter(cfg.Output)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create log writer: %w", err)
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
|
||||
switch strings.ToLower(cfg.Format) {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(writer, opts)
|
||||
case "console", "":
|
||||
handler = slog.NewTextHandler(writer, opts)
|
||||
default:
|
||||
writer.Close()
|
||||
return nil, nil, fmt.Errorf("invalid log format: %s (must be 'console' or 'json')", cfg.Format)
|
||||
}
|
||||
|
||||
logger := slog.New(handler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
return logger, writer, nil
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) (slog.Level, error) {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "info", "":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("unknown level: %s (must be debug, info, warn, or error)", level)
|
||||
}
|
||||
}
|
||||
|
||||
func newLogWriter(output string) (*LogWriter, error) {
|
||||
switch strings.ToLower(output) {
|
||||
case "stderr", "":
|
||||
return &LogWriter{file: os.Stderr}, nil
|
||||
case "stdout":
|
||||
return &LogWriter{file: os.Stdout}, nil
|
||||
default:
|
||||
file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file %s: %w", output, err)
|
||||
}
|
||||
return &LogWriter{path: output, file: file}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLogger_Invalid(t *testing.T) {
|
||||
if _, _, err := NewLogger(LoggingConfig{Format: "xml"}); err == nil {
|
||||
t.Error("expected error for format xml")
|
||||
}
|
||||
if _, _, err := NewLogger(LoggingConfig{Level: "loud"}); err == nil {
|
||||
t.Error("expected error for level loud")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogger_Levels(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
in string
|
||||
want slog.Level
|
||||
}{{"", slog.LevelInfo}, {"debug", slog.LevelDebug}, {"INFO", slog.LevelInfo}, {"warn", slog.LevelWarn}, {"warning", slog.LevelWarn}, {"error", slog.LevelError}} {
|
||||
got, err := parseLogLevel(tt.in)
|
||||
if err != nil || got != tt.want {
|
||||
t.Errorf("parseLogLevel(%q) = %v, %v; want %v", tt.in, got, err, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation: after the file is renamed away, Reopen must create a fresh file at
|
||||
// the configured path and subsequent writes must land there.
|
||||
func TestLogWriter_Reopen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.log")
|
||||
w, err := newLogWriter(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
if _, err := w.Write([]byte("one\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated := filepath.Join(dir, "app.log.0")
|
||||
if err := os.Rename(path, rotated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Reopen(); err != nil {
|
||||
t.Fatalf("Reopen: %v", err)
|
||||
}
|
||||
if _, err := w.Write([]byte("two\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, _ := os.ReadFile(rotated)
|
||||
fresh, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(old)) != "one" || strings.TrimSpace(string(fresh)) != "two" {
|
||||
t.Errorf("rotated=%q fresh=%q", old, fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogWriter_StderrReopenIsNoop(t *testing.T) {
|
||||
w, err := newLogWriter("stderr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Reopen(); err != nil {
|
||||
t.Errorf("Reopen on stderr: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Errorf("Close on stderr: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user