First commit Go counter service

This commit is contained in:
Dimitrije Dimitrijevic
2026-10-15 02:12:18 +02:00
commit c43ff17627
59 changed files with 11205 additions and 0 deletions
+369
View File
@@ -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
}
+288
View File
@@ -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)
}
}
+116
View File
@@ -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
}
}
+73
View File
@@ -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)
}
}