Add 80% limit warning, stop repeat notifications, fix review findings

Notifications:
- Warn once when a counter reaches warn_percent of its limit, through
  separate warn_*_url endpoints.
- Write ntf/ markers so a lost Redis reply cannot re-send a breach.
- Retry only when the request never left or the web app answered 429/5xx.

Fixes:
- Retry transactions Redis rejected; treat pool timeouts as definite.
- Install the signal handler before startup work.
- Stop logging the login suffix; redact notify URL credentials.
- Reject corrupt or empty CDBs, truncated hash tables included.
- Require a Sentinel password, refuse example placeholders, and default
  admin_addr to localhost.
- Normalise /counters domains, count on the arrival day, log rejections,
  hide internals in errors and exit 1 on a failed drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dimitrije Dimitrijevic
2026-11-09 09:01:56 +01:00
co-authored by Claude Opus 5.5
parent c43ff17627
commit 54f9626121
29 changed files with 1673 additions and 245 deletions
+53 -17
View File
@@ -90,20 +90,46 @@ Redis failures are handled per outcome: a key whose own `INCR` fails (it holds
a non-number) is logged `redis error on key …` while the other keys are counted
and checked for crossings; when the EXEC reply is lost after the write (timeout,
reset) the keys are re-read once from the master and the block says `redis
outcome unknown … re-read from master`, with a crossing reported when
`count >= limit`; a definite failure (connection refused) logs `redis error: …
(nothing counted)`.
outcome unknown … re-read from master`; a transaction Redis rejected as a whole
(READONLY or EXECABORT during a failover) applied nothing and is retried once
after 200 ms; a definite failure (connection refused, or a rejection that
repeats) logs `redis error: … (nothing counted)`.
The CDB is pulled from the agent host by `contrib/pull-domains.sh` (see
`contrib/README.md`): rsync, compare, verify, atomic `mv`, every 15 s.
A limit is "reached" by the increment that moves the counter from below the
limit to at or above it (`old < limit ≤ new`). Because INCRBY is atomic exactly
limit to at or above it (`old < limit ≤ new`). Because INCR is atomic exactly
one increment crosses, so the web app is notified once per level/id/flow/day —
`GET <org_url|address_url|domain_url>` with `$LEVEL $ID $FLOW $PERIOD(=d)
$ADDRESS $COUNT $LIMIT` substituted (URL-escaped), basic auth, in the
background, 5 attempts with exponential backoff; 4xx is not retried. An empty
URL disables notifications for that level.
background. An empty URL disables notifications for that level.
**Early warning.** The same rule at `warn_percent` of the limit (default 80,
rounded up, `0` = off) calls `warn_org_url` / `warn_address_url` /
`warn_domain_url`, which also accept `$PERCENT`. No warning URL means the
feature is off. A limit too small to have a threshold below it (1 or 2 at 80%)
has no warning. The warning URLs must not use the breach URL's path, because
the web app treats every call there as a breach; the daemon refuses to start
if they do. The web app endpoint is `GET /api/limits/warning`.
**No repeats.** A breach or warning is sent once:
- A confirmed crossing writes a marker, `ntf/<level>/<id>/d/<day>/<flow>/<limit|warn>`
(TTL 48 h). After a lost Redis reply a notification goes out only if `SET NX`
on that marker succeeds, so a counter that already notified stays silent. If
Redis cannot answer, nothing is sent and `_redis_errors_total{op="marker"}` rises.
- An attempt is retried only when the request never left or the web app answered
429 or 5xx, up to 5 attempts with exponential backoff. A request that was sent
but got no answer may have been acted on, so it is not retried; it is logged
as `outcome unknown`.
- The web app may `DEL` a day key to reset a customer; the next confirmed
crossing notifies again, and the web app's once-per-day guard decides whether
an email goes out.
Delivery is therefore at most once. A notification that fails, or is in flight
when the daemon stops past its 30 s deadline, is not sent later that day; look
for `failed limit` in the counting log and `_notifications_total{result="failed"}`.
## Counting transaction log
@@ -125,8 +151,10 @@ Every request writes one block in the shape of the old Lua plugin's log lines
`grep '<trace_id>'` returns one transaction; `grep 'skipped:'` every request
that counted nothing; `grep 'not usable'` every side that failed validation
(`sender address not usable: nobody (invalid e-mail address, not counted)`);
`grep 'dropped:'` every request refused by a full queue. A token login shows as
`… login_suffix=SUPPORTTOKEN` on the request line. `counting_format = "json"` emits the same events as one
`grep 'dropped:'` every request refused by a full queue; `grep 'rejected:'`
every `/in` or `/out` body refused by validation. A `user@dom@SUFFIX` login
shows as `… token_login=true` on the request line; the suffix may be a
credential and is never logged. `counting_format = "json"` emits the same events as one
JSON object per line. Domains with thousands of per-mailbox overrides print the
base keys plus the address's own overrides and a count of the rest.
@@ -140,16 +168,19 @@ migadu_counters -version
Foreground process; two listeners: `addr` (API, default `:5555`) and
`admin_addr` (`/health`, `/metrics`, no auth). Signals: `SIGHUP` reopens both
log files and reloads the CDB; `SIGTERM`/`SIGINT` stop accepting, drain the
counting queue (every accepted request is counted), wait for in-flight
notifications (≤ 30 s) and exit 0. On FreeBSD run it like rcptd: an rc.d script
counting queue, wait for in-flight notifications and their retries (≤ 30 s)
and exit 0. If the deadline passes first, the remaining jobs are lost and the
exit code is 1. On FreeBSD run it like rcptd: an rc.d script
with `/usr/sbin/daemon -f -H -p <pidfile> -o <logfile>`; `service
migadu_counters reload` sends SIGHUP. There is deliberately no self-daemonising
or pidfile option (daemon(8) owns both). Startup fails without a readable CDB;
an unreachable Redis is only warned about (`/health` answers 503 until it is
or pidfile option (daemon(8) owns both). Startup fails without a readable, non-empty
CDB; an unreachable Redis is only warned about (`/health` answers 503 until it is
back).
See `config.toml.example` for every option. `auth_token` is mandatory and
unknown keys (typos such as `auth-token`) are fatal at startup. Secrets may
See `config.toml.example` for every option. `auth_token` is mandatory, the
example's `"…"` placeholders are refused, `admin_addr` defaults to
`127.0.0.1:9095`, `redis.password` is required with Sentinel (unless
`redis.password_optional = true`) and unknown keys (typos such as `auth-token`) are fatal at startup. Secrets may
come from the environment: `MIGADU_COUNTERS_AUTH_TOKEN`,
`MIGADU_COUNTERS_REDIS_PASSWORD`, `MIGADU_COUNTERS_NOTIFY_PASSWORD`. `redis.db` must stay 0. Production uses
Sentinel (`sentinels`, `master_name = "mymaster"`): writes go to the master,
@@ -158,8 +189,9 @@ reads to a replica (falling back to the master when none is known); without
Metrics (`/metrics` on the admin port): `migadu_counters_requests_total{route,status}`,
`_request_duration_seconds{route}`, `_increments_total{level,flow}`,
`_limit_reached_total{level,flow}`, `_notifications_total{level,result}`,
`_redis_errors_total{op}` (`incr`, `incr_unknown`, `mget`, `ping`),
`_limit_reached_total{level,flow}`, `_warning_reached_total{level,flow}`,
`_notifications_total{kind,level,result}` (`kind`: `limit`, `warning`),
`_redis_errors_total{op}` (`incr`, `incr_unknown`, `mget`, `ping`, `marker`),
`_dropped_total{route}`, `_queue_depth`, `_queue_wait_seconds`, `_cdb_domains`,
`_cdb_reloads_total{result}`.
@@ -195,12 +227,16 @@ on any error.
```
make build # bin/migadu_counters, bin/counterctl
make test # go test -race ./... (tests use test_files/rspamd_domains_db.cdb and miniredis)
make test # go test -race ./... (tests use miniredis and test_files/rspamd_domains_db.cdb)
make vet # go vet + gofmt
make test-contrib # shell test for contrib/pull-domains.sh (needs tinycdb cdb + rsync)
make freebsd # static FreeBSD amd64 binaries for the Ansible role
```
`test_files/` is gitignored because the CDB holds real customer domains, so on
a fresh clone the tests fail until you copy `rspamd_domains_db` from an agent
host (`/usr/local/etc/agent/build/rspamd/`) to `test_files/rspamd_domains_db.cdb`.
Manual check with a local Redis: `redis-server --port 6379`, a config in static
mode (`write_addr = "127.0.0.1:6379"`) pointing `domains_cdb` at
`test_files/rspamd_domains_db.cdb`, then
+14 -2
View File
@@ -78,7 +78,12 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
}
q.Set("address", addr)
} else {
q.Set("domain", strings.ToLower(strings.TrimSpace(*domain)))
dom, err := api.NormalizeDomain(*domain)
if err != nil {
fmt.Fprintf(stderr, "invalid domain %q: %v\n", *domain, err)
return 1
}
q.Set("domain", dom)
}
if *dateFlag != "" {
day, err := api.ParseDate(*dateFlag, now())
@@ -87,7 +92,8 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
return 1
}
q.Set("date", day)
if t, _ := time.Parse("2006-01-02", day); now().UTC().Sub(t) > 31*24*time.Hour {
today, _ := api.ParseDate("", now())
if d, t := mustDay(today), mustDay(day); d.Sub(t) > 31*24*time.Hour {
fmt.Fprintf(stderr, "warning: %s is more than 31 days ago; counter keys expire after 31 days and will read as 0\n", day)
}
}
@@ -154,6 +160,12 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
return 0
}
// mustDay parses a date api.ParseDate already validated.
func mustDay(s string) time.Time {
t, _ := time.Parse("2006-01-02", s)
return t
}
// flows picks the table columns: --in or --out alone restrict the table to
// that flow; neither or both show both flows.
func flows(in, out bool) (showIn, showOut bool) {
+35
View File
@@ -57,6 +57,8 @@ func (f *fakeAPI) last() *http.Request {
return f.requests[len(f.requests)-1]
}
func (f *fakeAPI) rawQuery() string { return f.last().URL.RawQuery }
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.toml")
@@ -333,3 +335,36 @@ func TestRun_Version(t *testing.T) {
t.Errorf("exit %d output %q", code, out)
}
}
// Keys live 31 days from their last increment, so a date exactly 31 days back
// can still hold data.
func TestDateWarning_Boundary(t *testing.T) {
f := &fakeAPI{body: sampleResponse()}
s := httptest.NewServer(f)
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
exact := now.UTC().AddDate(0, 0, -31).Format("2006-01-02")
if _, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", exact); strings.Contains(errOut, "will read as 0") {
t.Errorf("31 days back must not warn: %q", errOut)
}
older := now.UTC().AddDate(0, 0, -32).Format("2006-01-02")
if _, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", older); !strings.Contains(errOut, "will read as 0") {
t.Errorf("32 days back must warn: %q", errOut)
}
}
func TestDomainFlag_Normalised(t *testing.T) {
f := &fakeAPI{body: sampleResponse()}
s := httptest.NewServer(f)
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
if code, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--domain", "Bücher.de"); code != 0 {
t.Fatalf("exit %d: %s", code, errOut)
}
if !strings.Contains(f.rawQuery(), "domain=xn--bcher-kva.de") {
t.Errorf("query = %q", f.rawQuery())
}
if code, _, _ := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--domain", "a..b"); code == 0 {
t.Error("an invalid domain must exit non-zero")
}
}
+25 -8
View File
@@ -44,6 +44,9 @@ var (
date = "unknown"
)
// beforeStartup lets tests act right after the signal handler is installed.
var beforeStartup func()
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, nil))
}
@@ -63,6 +66,18 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
return 0
}
// Installed before anything slow: a SIGHUP's default action is to kill the
// process. Signals that arrive during startup wait in the channel.
if sigs == nil {
ch := make(chan os.Signal, 8)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(ch)
sigs = ch
}
if beforeStartup != nil {
beforeStartup()
}
path, err := config.Discover(*configPath)
if err != nil {
fmt.Fprintln(stderr, err)
@@ -93,12 +108,6 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
}
a.Serve(apiLn, adminLn)
if sigs == nil {
ch := make(chan os.Signal, 2)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(ch)
sigs = ch
}
for {
select {
case sig := <-sigs:
@@ -115,6 +124,7 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
cancel()
if err != nil {
fmt.Fprintf(stderr, "shutdown: %v\n", err)
return 1
}
return 0
case err := <-a.fatal:
@@ -166,6 +176,11 @@ func newApp(cfg *config.Config) (*app, error) {
logWriter.Close()
return nil, err
}
if db.Count() == 0 {
db.Close()
logWriter.Close()
return nil, fmt.Errorf("domains db %s holds no records", cfg.DomainsCDB)
}
a.db.Store(db)
logger.Info("domains db loaded", "path", cfg.DomainsCDB, "domains", db.Count(), "max_cached_domains", cfg.MaxCachedDomains)
@@ -209,6 +224,7 @@ func newApp(cfg *config.Config) (*app, error) {
a.notifier = notify.New(notify.Config{
OrgURL: cfg.Notify.OrgURL, AddressURL: cfg.Notify.AddressURL, DomainURL: cfg.Notify.DomainURL,
WarnOrgURL: cfg.Notify.WarnOrgURL, WarnAddressURL: cfg.Notify.WarnAddressURL, WarnDomainURL: cfg.Notify.WarnDomainURL,
User: cfg.Notify.User, Password: cfg.Notify.Password, Timeout: cfg.Notify.Timeout(),
}, server.NotifyHooks(tx, a.m))
@@ -220,8 +236,9 @@ func newApp(cfg *config.Config) (*app, error) {
}
return db.Lookup(domain)
}),
Exempt: cfg.ExemptSet(),
CountJunk: cfg.CountJunk,
Exempt: cfg.ExemptSet(),
CountJunk: cfg.CountJunk,
WarnPercent: cfg.Notify.EffectiveWarnPercent(),
}
a.srv = server.New(server.Deps{
AuthToken: cfg.AuthToken,
+47
View File
@@ -415,3 +415,50 @@ func TestRun_SignalLoop(t *testing.T) {
t.Errorf("log:\n%s", e.logs())
}
}
// A SIGHUP while the daemon starts must not kill it, and the reload it asked
// for runs once the daemon is ready.
func TestRun_SIGHUPDuringStartupIsQueued(t *testing.T) {
e := newEnv(t, "")
beforeStartup = func() { syscall.Kill(os.Getpid(), syscall.SIGHUP) }
t.Cleanup(func() { beforeStartup = nil })
done := make(chan int, 1)
var out, errb bytes.Buffer
go func() { done <- run([]string{"-config", e.cfgPath}, &out, &errb, nil) }()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(e.logs(), "domains db reloaded") {
if time.Now().After(deadline) {
t.Fatalf("the early SIGHUP was not honoured; log:\n%s", e.logs())
}
time.Sleep(20 * time.Millisecond)
}
syscall.Kill(os.Getpid(), syscall.SIGTERM)
select {
case code := <-done:
if code != 0 {
t.Errorf("exit code %d, stderr %q", code, errb.String())
}
case <-time.After(15 * time.Second):
t.Fatal("run did not return after SIGTERM")
}
}
func TestNewApp_EmptyCDBIsFatal(t *testing.T) {
e := newEnv(t, "")
w, err := cdb.Create(e.cdbPath)
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(e.cfgPath)
if err != nil {
t.Fatal(err)
}
if a, err := newApp(cfg); err == nil {
a.Shutdown(context.Background())
t.Fatal("an empty domains db must be fatal at startup")
}
}
+12 -4
View File
@@ -2,10 +2,10 @@
# Install as /usr/local/etc/migadu_counters/config.toml (or pass -config PATH).
addr = ":5555" # API: POST /in, POST /out, GET /counters
admin_addr = ":9095" # /health and /metrics, no auth
admin_addr = "127.0.0.1:9095" # /health and /metrics, no auth: keep it private
auth_token = "…" # REQUIRED (or MIGADU_COUNTERS_AUTH_TOKEN); same value in mailqueuer's config
allowed_cidrs = [] # optional source-IP allowlist for the API, e.g. ["10.0.0.0/8"]
domains_cdb = "/usr/local/etc/rspamd/db/rspamd_domains_db"
domains_cdb = "/usr/local/etc/migadu_counters/db/rspamd_domains_db" # where contrib/pull-domains.sh installs it
exempt = ["support@migadu.com", "contact@migadu.com"] # mail to/from these never counts
count_junk = false # count /in requests flagged junk by mizu
@@ -33,7 +33,8 @@ counting_format = "text" # Lua-like lines (default) or "json"
[redis]
db = 0 # must stay 0: migadu_web reads message counters from DB 0
password = "…" # or MIGADU_COUNTERS_REDIS_PASSWORD
password = "…" # or MIGADU_COUNTERS_REDIS_PASSWORD; required with sentinels
# password_optional = true # development only: allow Sentinel without a password
pool_size = 32
timeout_ms = 500
# Sentinel mode (production): writes -> master, reads -> replicas
@@ -50,6 +51,13 @@ sentinel_password = ""
org_url = "https://admin.migadu.com/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
address_url = "https://admin.migadu.com/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
domain_url = "" # web app's D branch is still a TODO
# Early warning: one call when a counter reaches warn_percent of its limit
# (0 = off). Empty URLs disable a level. These must not be the /api/limits
# URLs above: the web app treats every call there as a breach.
warn_percent = 80
warn_org_url = "" # e.g. https://admin.migadu.com/api/limits/warning?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT&limit=$LIMIT&percent=$PERCENT
warn_address_url = ""
warn_domain_url = ""
user = "…"
password = "…" # or MIGADU_COUNTERS_NOTIFY_PASSWORD
timeout_seconds = 5
timeout_seconds = 15 # per attempt; a request sent but unanswered is not retried
+32 -1
View File
@@ -22,7 +22,7 @@ func TestValidateOut(t *testing.T) {
// Support/token logins (review #3): mizu forwards the raw SASL login
// user@dom@SUFFIX; the base address is counted, the suffix logged.
{"login suffix", OutRequest{AuthUser: "x@yagolivas.com@SUPPORTTOKEN", Recipient: "y@example.com"}, "",
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, LoginSuffix: "SUPPORTTOKEN", Recipient: Address{Value: "y@example.com"}}},
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, TokenLogin: true, Recipient: Address{Value: "y@example.com"}}},
// mail_from never rejects (review #2): kept normalised when possible,
// otherwise as a sanitised string for the log.
{"mail_from empty", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: ""}, "",
@@ -253,3 +253,34 @@ func TestErrorResponseJSON(t *testing.T) {
t.Errorf("got %s", b)
}
}
func TestNormalizeDomain(t *testing.T) {
tests := []struct{ in, want string }{
{"Example.COM", "example.com"},
{" example.com. ", "example.com"},
{"bücher.de", "xn--bcher-kva.de"},
{"xn--bcher-kva.de", "xn--bcher-kva.de"},
}
for _, tt := range tests {
got, err := NormalizeDomain(tt.in)
if err != nil || got != tt.want {
t.Errorf("NormalizeDomain(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
}
}
for _, bad := range []string{"", ".", "a..b", ".a", "host_name.example.com", "[192.0.2.1]", "a b.com"} {
if got, err := NormalizeDomain(bad); err == nil {
t.Errorf("NormalizeDomain(%q) = %q, want an error", bad, got)
}
}
}
// An unusable login is logged, but never with what follows a second "@".
func TestValidateOut_UnusableLoginDropsSuffix(t *testing.T) {
v, err := ValidateOut(OutRequest{AuthUser: "x@bad..domain@SECRET", Recipient: "y@example.com"})
if err != nil {
t.Fatal(err)
}
if v.AuthUser.Usable() || v.AuthUser.Raw != "x@bad..domain" {
t.Errorf("AuthUser = %+v, want the raw value without the suffix", v.AuthUser)
}
}
+33 -12
View File
@@ -40,7 +40,7 @@ func (a Address) Usable() bool { return a.Value != "" }
type ValidatedOut struct {
TraceID string
AuthUser Address
LoginSuffix string // "SUPPORTTOKEN" of a user@dom@SUPPORTTOKEN login
TokenLogin bool // a user@dom@SUFFIX login; the suffix may be a credential and is dropped
MailFrom string // normalised, or the sanitised input when not an address
MailFromUsable bool
Recipient Address
@@ -60,7 +60,7 @@ type ValidatedIn struct {
// mail_from only feeds the exempt check and never rejects.
func ValidateOut(r OutRequest) (ValidatedOut, error) {
v := ValidatedOut{TraceID: SanitizeTraceID(r.TraceID)}
v.AuthUser, v.LoginSuffix = validateAuthUser(r.AuthUser)
v.AuthUser, v.TokenLogin = validateAuthUser(r.AuthUser)
v.Recipient = validateAddress(r.Recipient)
v.MailFrom, v.MailFromUsable = lenient(r.MailFrom)
if !v.AuthUser.Usable() && !v.Recipient.Usable() {
@@ -93,15 +93,26 @@ func validateAddress(s string) Address {
return Address{Value: n}
}
func validateAuthUser(s string) (Address, string) {
func validateAuthUser(s string) (Address, bool) {
if strings.TrimSpace(s) == "" {
return Address{Reason: "missing"}, ""
return Address{Reason: "missing"}, false
}
n, suffix, err := NormalizeAuthUser(s)
if err != nil {
return Address{Raw: SanitizeForLog(s), Reason: "invalid e-mail address"}, ""
return Address{Raw: redactSuffix(s), Reason: "invalid e-mail address"}, false
}
return Address{Value: n}, suffix
return Address{Value: n}, suffix != ""
}
// redactSuffix prepares an unusable login for the log: anything after a second
// "@" may be a credential, so it is cut off first.
func redactSuffix(s string) string {
if first := strings.Index(s, "@"); first >= 0 {
if second := strings.Index(s[first+1:], "@"); second >= 0 {
s = s[:first+1+second]
}
}
return SanitizeForLog(s)
}
// lenient normalises when possible and otherwise keeps a sanitised copy for
@@ -159,7 +170,21 @@ func NormalizeEmail(s string) (string, error) {
return "", errors.New("local part contains whitespace or control characters")
}
}
domain = strings.TrimSuffix(domain, ".")
ascii, err := NormalizeDomain(domain)
if err != nil {
return "", err
}
out := local + "@" + ascii
if len(out) > MaxEmailLen {
return "", errors.New("longer than 254 characters")
}
return out, nil
}
// NormalizeDomain trims and lowercases a domain, drops one trailing dot and
// converts a Unicode domain to punycode, the form the CDB keys it under.
func NormalizeDomain(s string) (string, error) {
domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
if domain == "" || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
return "", errors.New("domain has an empty label")
}
@@ -173,11 +198,7 @@ func NormalizeEmail(s string) (string, error) {
return "", errors.New("domain contains characters other than a-z, 0-9, '-' and '.'")
}
}
out := local + "@" + ascii
if len(out) > MaxEmailLen {
return "", errors.New("longer than 254 characters")
}
return out, nil
return ascii, nil
}
// SanitizeTraceID trims the id, strips control characters (it ends up in log
+68 -9
View File
@@ -29,6 +29,10 @@ const (
EnvNotifyPassword = "MIGADU_COUNTERS_NOTIFY_PASSWORD"
)
// examplePlaceholder marks the secrets in config.toml.example that must be
// replaced before use.
const examplePlaceholder = "…"
// 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.
@@ -84,10 +88,12 @@ func (l LoggingConfig) CountingEnabled() bool { return l.CountingOutput != "off"
// 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"`
DB int `toml:"db"`
Password string `toml:"password"`
// PasswordOptional allows Sentinel mode without a password (development).
PasswordOptional bool `toml:"password_optional"`
PoolSize int `toml:"pool_size"`
TimeoutMS int `toml:"timeout_ms"`
MasterName string `toml:"master_name"`
Sentinels []string `toml:"sentinels"`
@@ -106,9 +112,15 @@ func (r RedisConfig) Timeout() time.Duration { return time.Duration(r.TimeoutMS)
// 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"`
OrgURL string `toml:"org_url"`
AddressURL string `toml:"address_url"`
DomainURL string `toml:"domain_url"`
// Early warning: WarnPercent of a limit triggers a call to the warn_* URL
// of that level (0 = off). An empty URL disables that level.
WarnPercent int64 `toml:"warn_percent"`
WarnOrgURL string `toml:"warn_org_url"`
WarnAddressURL string `toml:"warn_address_url"`
WarnDomainURL string `toml:"warn_domain_url"`
User string `toml:"user"`
Password string `toml:"password"`
TimeoutSeconds int `toml:"timeout_seconds"`
@@ -117,11 +129,20 @@ type NotifyConfig struct {
// Timeout is the per-attempt HTTP timeout for a notification.
func (n NotifyConfig) Timeout() time.Duration { return time.Duration(n.TimeoutSeconds) * time.Second }
// EffectiveWarnPercent is WarnPercent, or 0 when no warning URL is configured,
// so an unused feature adds no markers, metrics or log lines.
func (n NotifyConfig) EffectiveWarnPercent() int64 {
if n.WarnOrgURL == "" && n.WarnAddressURL == "" && n.WarnDomainURL == "" {
return 0
}
return n.WarnPercent
}
// Default returns the configuration with every default applied.
func Default() Config {
return Config{
Addr: ":5555",
AdminAddr: ":9095",
AdminAddr: "127.0.0.1:9095",
Exempt: []string{"support@migadu.com", "contact@migadu.com"},
QueueSize: 10000,
Workers: 8,
@@ -132,7 +153,7 @@ func Default() Config {
HandlerTimeout: 4,
Logging: LoggingConfig{Output: "stderr", Format: "console", Level: "info", CountingFormat: "text"},
Redis: RedisConfig{PoolSize: 32, TimeoutMS: 500},
Notify: NotifyConfig{TimeoutSeconds: 5},
Notify: NotifyConfig{TimeoutSeconds: 15, WarnPercent: 80},
}
}
@@ -225,6 +246,13 @@ func (c *Config) Validate() error {
if strings.TrimSpace(c.AuthToken) == "" {
fail("auth_token is required; set it in the config or %s", EnvAuthToken)
}
for _, p := range []struct{ name, value string }{
{"auth_token", c.AuthToken}, {"redis.password", c.Redis.Password}, {"notify.password", c.Notify.Password},
} {
if strings.TrimSpace(p.value) == examplePlaceholder {
fail("%s still holds the example placeholder %q", p.name, examplePlaceholder)
}
}
if c.QueueSize <= 0 {
fail("queue_size must be > 0")
}
@@ -282,6 +310,9 @@ func (c *Config) Validate() error {
fail("redis.timeout_ms must be > 0")
}
if c.Redis.SentinelMode() {
if c.Redis.Password == "" && !c.Redis.PasswordOptional {
fail("redis.password is required in Sentinel mode; set it or %s, or set redis.password_optional = true", EnvRedisPassword)
}
if c.Redis.MasterName == "" {
fail("redis.master_name is required when redis.sentinels is set")
}
@@ -315,6 +346,18 @@ func (c *Config) Validate() error {
if c.Notify.TimeoutSeconds <= 0 {
fail("notify.timeout_seconds must be > 0")
}
if c.Notify.WarnPercent < 0 || c.Notify.WarnPercent > 99 {
fail("notify.warn_percent must be between 0 and 99 (got %d)", c.Notify.WarnPercent)
}
for _, p := range []struct{ name, warn, breach string }{
{"warn_org_url", c.Notify.WarnOrgURL, c.Notify.OrgURL},
{"warn_address_url", c.Notify.WarnAddressURL, c.Notify.AddressURL},
{"warn_domain_url", c.Notify.WarnDomainURL, c.Notify.DomainURL},
} {
if p.warn != "" && p.breach != "" && samePath(p.warn, p.breach) {
fail("notify.%s must not use the same path as the breach URL: the web app treats every call there as a breach", p.name)
}
}
return errors.Join(errs...)
}
@@ -331,6 +374,9 @@ func (c *Config) Warnings() []string {
w = append(w, fmt.Sprintf("notify.%s: missing %s placeholder (the web app requires all six query parameters)", u.name, ph))
}
}
if c.Notify.User != "" && strings.HasPrefix(strings.ToLower(u.url), "http://") {
w = append(w, fmt.Sprintf("notify.%s: basic auth credentials would travel over plain http", u.name))
}
}
return w
}
@@ -340,9 +386,22 @@ func (c *Config) notifyURLs() []struct{ name, url string } {
{"org_url", c.Notify.OrgURL},
{"address_url", c.Notify.AddressURL},
{"domain_url", c.Notify.DomainURL},
{"warn_org_url", c.Notify.WarnOrgURL},
{"warn_address_url", c.Notify.WarnAddressURL},
{"warn_domain_url", c.Notify.WarnDomainURL},
}
}
// samePath reports whether two URLs differ at most in their query string.
func samePath(a, b string) bool {
ua, errA := url.Parse(a)
ub, errB := url.Parse(b)
if errA != nil || errB != nil {
return false
}
return ua.Scheme == ub.Scheme && ua.Host == ub.Host && strings.TrimRight(ua.Path, "/") == strings.TrimRight(ub.Path, "/")
}
// ExemptSet returns the normalised exempt addresses.
func (c *Config) ExemptSet() map[string]bool { return c.exempt }
+53 -6
View File
@@ -43,7 +43,7 @@ func TestLoad_Defaults(t *testing.T) {
want any
}{
{"addr", cfg.Addr, ":5555"},
{"admin_addr", cfg.AdminAddr, ":9095"},
{"admin_addr", cfg.AdminAddr, "127.0.0.1:9095"},
{"auth_token", cfg.AuthToken, "t"},
{"queue_size", cfg.QueueSize, 10000},
{"workers", cfg.Workers, 8},
@@ -62,9 +62,10 @@ func TestLoad_Defaults(t *testing.T) {
{"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},
{"notify.timeout_seconds", cfg.Notify.TimeoutSeconds, 15},
{"notify.warn_percent", cfg.Notify.WarnPercent, int64(80)},
{"redis timeout", cfg.Redis.Timeout(), 500 * time.Millisecond},
{"notify timeout", cfg.Notify.Timeout(), 5 * time.Second},
{"notify timeout", cfg.Notify.Timeout(), 15 * time.Second},
{"handler timeout", cfg.HandlerTimeoutDuration(), 4 * time.Second},
}
for _, c := range checks {
@@ -86,9 +87,17 @@ func TestLoad_Defaults(t *testing.T) {
}
}
// The shipped example must always load, validate and produce no warnings.
// The shipped example must load and validate once its placeholders are filled
// in, produce no warnings, and be refused while they are still there.
func TestLoad_Example(t *testing.T) {
cfg, err := Load("../../config.toml.example")
if _, err := Load("../../config.toml.example"); err == nil || !strings.Contains(err.Error(), "placeholder") {
t.Fatalf("the unedited example must be refused, got %v", err)
}
raw, err := os.ReadFile("../../config.toml.example")
if err != nil {
t.Fatal(err)
}
cfg, err := Load(writeConfig(t, strings.ReplaceAll(string(raw), "\"…\"", "\"filled-in\"")))
if err != nil {
t.Fatalf("Load(config.toml.example): %v", err)
}
@@ -164,7 +173,7 @@ func TestLoad_AllowedCIDRs(t *testing.T) {
}
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")
cfg := mustLoad(t, top+"[redis]\npassword = \"s\"\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)
}
@@ -202,6 +211,14 @@ func TestValidate_Errors(t *testing.T) {
{"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"},
{"sentinel needs password", top + "[redis]\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1:26379\"]\n", "redis.password"},
{"placeholder token", "domains_cdb = \"/tmp/x\"\nauth_token = \"…\"\n" + redisStatic, "auth_token"},
{"placeholder redis password", top + redisStatic + "password = \"…\"\n", "redis.password"},
{"placeholder notify password", minimal + "[notify]\npassword = \"…\"\n", "notify.password"},
{"warn percent high", minimal + "[notify]\nwarn_percent = 100\n", "warn_percent"},
{"warn percent negative", minimal + "[notify]\nwarn_percent = -1\n", "warn_percent"},
{"warn url scheme", minimal + "[notify]\nwarn_org_url = \"ftp://x/w\"\n", "warn_org_url"},
{"warn url same as breach", minimal + "[notify]\norg_url = \"https://x/api/limits?level=$LEVEL\"\nwarn_org_url = \"https://x/api/limits?level=$LEVEL&percent=$PERCENT\"\n", "warn_org_url"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -286,3 +303,33 @@ func TestDiscover(t *testing.T) {
t.Errorf("Discover(\"\") = %q, %v; want %q", got, err, found)
}
}
// An explicit opt-out allows a passwordless Sentinel setup (development).
func TestLoad_SentinelPasswordOptional(t *testing.T) {
cfg := mustLoad(t, top+"[redis]\npassword_optional = true\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1:26379\"]\n")
if !cfg.Redis.SentinelMode() {
t.Errorf("config = %+v", cfg.Redis)
}
}
func TestWarnings_PlainHTTPWithBasicAuth(t *testing.T) {
const q = "?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
cfg := mustLoad(t, minimal+"[notify]\nuser = \"u\"\norg_url = \"http://x/l"+q+"\"\n")
w := strings.Join(cfg.Warnings(), "\n")
if !strings.Contains(w, "org_url") || !strings.Contains(w, "plain http") {
t.Errorf("Warnings() = %q, want a plain-http warning for org_url", w)
}
cfg = mustLoad(t, minimal+"[notify]\nuser = \"u\"\norg_url = \"https://x/l"+q+"\"\n")
if w := cfg.Warnings(); len(w) != 0 {
t.Errorf("https should not warn: %v", w)
}
}
func TestEffectiveWarnPercent(t *testing.T) {
if got := (NotifyConfig{WarnPercent: 80}).EffectiveWarnPercent(); got != 0 {
t.Errorf("no warning URLs: %d, want 0", got)
}
if got := (NotifyConfig{WarnPercent: 80, WarnOrgURL: "https://x/w"}).EffectiveWarnPercent(); got != 80 {
t.Errorf("with a warning URL: %d, want 80", got)
}
}
+120 -23
View File
@@ -56,6 +56,8 @@ type Planner struct {
Exempt map[string]bool // lowercased addresses that never count
CountJunk bool // count /in requests flagged junk
Now func() time.Time // nil = time.Now; the UTC day of Now() is the counter day
// WarnPercent is the share of a limit that triggers an early warning; 0 = off.
WarnPercent int64
}
// Job is one validated request handed to the queue: the fields the counting
@@ -66,9 +68,9 @@ type Job struct {
TraceID string
Received time.Time // when the request arrived (queue wait metric)
AuthUser string // /out: normalised SMTP AUTH user, "" when not usable
LoginSuffix string // /out: "SUPPORTTOKEN" of a user@dom@SUPPORTTOKEN login
MailFrom string // normalised, or the sanitised input when not an address
AuthUser string // /out: normalised SMTP AUTH user, "" when not usable
TokenLogin bool // /out: a user@dom@SUFFIX login
MailFrom string // normalised, or the sanitised input when not an address
// MailFromUsable tells whether MailFrom is a normalised address (and may
// match the exempt list); an unusable value is never exempt.
MailFromUsable bool
@@ -107,20 +109,21 @@ type Increment struct {
By string // address that caused it (sender for o, recipient for i); $ADDRESS in notifications
Limit int64 // configured limit, 0 when unlimited
Limited bool // false when no limit is configured for this level/flow
WarnAt int64 // counter value that triggers the early warning, 0 = none
Domain *domains.Domain
}
// Plan is the result of applying the rules to one request.
type Plan struct {
Route string
TraceID string
Date string // UTC day used in the keys
Skipped string // "" or one of the Skip* reasons
AuthUser string
LoginSuffix string
MailFrom string
Recipient string
Junk bool // /in only: the message was flagged junk by mizu
Route string
TraceID string
Date string // UTC day used in the keys
Skipped string // "" or one of the Skip* reasons
AuthUser string
TokenLogin bool
MailFrom string
Recipient string
Junk bool // /in only: the message was flagged junk by mizu
Unusable []Unusable // sides that were not counted because they failed validation
Lookups []Lookup
@@ -166,8 +169,12 @@ func (p *Planner) Plan(job Job) (*Plan, error) {
if job.MailFromUsable {
mailFrom = normalize(mailFrom)
}
plan := &Plan{Route: job.Route, TraceID: job.TraceID, Date: store.DayKey(p.now()),
AuthUser: authUser, LoginSuffix: job.LoginSuffix, MailFrom: mailFrom, Recipient: recipient, Junk: job.Junk}
counted := job.Received
if counted.IsZero() {
counted = p.now()
}
plan := &Plan{Route: job.Route, TraceID: job.TraceID, Date: store.DayKey(counted),
AuthUser: authUser, TokenLogin: job.TokenLogin, MailFrom: mailFrom, Recipient: recipient, Junk: job.Junk}
if job.SenderUnusable.Set() {
plan.Unusable = append(plan.Unusable, job.SenderUnusable)
}
@@ -234,11 +241,33 @@ func (p *Planner) add(plan *Plan, role, address, flow string) error {
Level: l.level, ID: l.id, Flow: flow,
Key: store.Key(l.level, l.id, plan.Date, flow),
By: address, Limit: limit, Limited: limited, Domain: d,
WarnAt: warnAt(limit, limited, p.WarnPercent),
})
}
return nil
}
func warnAt(limit int64, limited bool, percent int64) int64 {
if !limited {
return 0
}
return WarnThreshold(limit, percent)
}
// WarnThreshold returns the counter value at which percent of limit is used,
// rounded up, or 0 when no warning applies: warnings are off, there is no
// positive limit, or the threshold is not below the limit itself.
func WarnThreshold(limit, percent int64) int64 {
if limit <= 0 || percent <= 0 {
return 0
}
t := limit/100*percent + (limit%100*percent+99)/100
if t >= limit {
return 0
}
return t
}
// Crossed reports whether an increment of amount that produced newValue is
// the one that reached limit: old < limit <= new. Because INCRBY is atomic
// exactly one increment can cross, so this is the once-per-crossing trigger
@@ -251,8 +280,12 @@ func Crossed(newValue, amount, limit int64) bool {
type Result struct {
Increment
Count int64 // value after the increment (from EXEC, or re-read after a lost reply)
Reached bool // this increment crossed the limit
Reached bool // this increment crossed the limit and is the one to notify
Warned bool // this increment crossed the warning threshold and is the one to notify
Err error // this key's own INCR failed (e.g. it holds a non-number); Count is 0
// MarkerErr is set when the notification marker could not be read or
// written. After a lost reply the notification is then withheld.
MarkerErr error
}
// Outcome is an executed Plan.
@@ -269,6 +302,9 @@ type Counter struct {
Planner *Planner
Store *store.Store
ReReadTimeout time.Duration // budget for the master re-read after a lost reply (default 2s)
// AbortRetryDelay is the pause before the single retry of a transaction
// Redis rejected, e.g. during a failover (default 200ms).
AbortRetryDelay time.Duration
}
// CountOut plans and executes an /out request.
@@ -293,12 +329,17 @@ func (c *Counter) CountIn(ctx context.Context, traceID, mailFrom, recipient stri
// not touch Redis.
//
// Failure handling (review #4): a key whose own INCR failed is reported in
// its Result.Err while the other keys are counted normally. When the whole
// reply was lost (timeout, reset) after the write may have been applied, the
// keys are re-read once from the master and the outcome is flagged Unknown;
// crossing detection then uses count >= limit so a crossing that happened in
// the lost transaction is still notified. A definite failure (connection
// refused, cancelled) is returned as an error: nothing was counted.
// its Result.Err while the other keys are counted normally. A transaction
// Redis rejected as a whole applied nothing and is retried once. When the
// whole reply was lost (timeout, reset) after the write may have been applied,
// the keys are re-read once from the master and the outcome is flagged
// Unknown. A definite failure (connection refused, cancelled) is returned as
// an error: nothing was counted.
//
// Notifications go out once per counter: a confirmed crossing is exact, so it
// always fires and writes a marker. After a lost reply the crossing cannot be
// pinned to this increment, so it fires only for the caller that wins the
// marker, and a counter that already notified stays silent.
func (c *Counter) Execute(ctx context.Context, plan *Plan) (*Outcome, error) {
out := &Outcome{Plan: plan}
if len(plan.Increments) == 0 {
@@ -309,13 +350,15 @@ func (c *Counter) Execute(ctx context.Context, plan *Plan) (*Outcome, error) {
keys[i] = inc.Key
}
out.Results = make([]Result, len(plan.Increments))
results, err := c.Store.Incr(ctx, keys)
results, err := c.incr(ctx, keys)
if err == nil {
for i, inc := range plan.Increments {
r := Result{Increment: inc, Err: results[i].Err}
if r.Err == nil {
r.Count = results[i].Count
r.Reached = inc.Limited && Crossed(r.Count, 1, inc.Limit)
r.Warned = Crossed(r.Count, 1, inc.WarnAt)
c.mark(ctx, plan.Date, &r)
}
out.Results[i] = r
}
@@ -332,11 +375,65 @@ func (c *Counter) Execute(ctx context.Context, plan *Plan) (*Outcome, error) {
}
out.Unknown = err
for i, inc := range plan.Increments {
out.Results[i] = Result{Increment: inc, Count: counts[i], Reached: inc.Limited && counts[i] >= inc.Limit}
r := Result{Increment: inc, Count: counts[i]}
switch {
case inc.Limited && inc.Limit > 0 && r.Count >= inc.Limit:
r.Reached = c.claim(rctx, plan.Date, &r, store.MarkerLimit)
case inc.WarnAt > 0 && r.Count >= inc.WarnAt:
r.Warned = c.claim(rctx, plan.Date, &r, store.MarkerWarn)
}
out.Results[i] = r
}
return out, nil
}
// incr runs the transaction and retries it once when Redis rejected it.
func (c *Counter) incr(ctx context.Context, keys []string) ([]store.IncrResult, error) {
results, err := c.Store.Incr(ctx, keys)
if !errors.Is(err, store.ErrNotApplied) {
return results, err
}
select {
case <-time.After(c.abortRetryDelay()):
case <-ctx.Done():
return nil, err
}
return c.Store.Incr(ctx, keys)
}
// mark records a confirmed crossing. A failure is reported but the
// notification still goes out, because this crossing is certain.
func (c *Counter) mark(ctx context.Context, date string, r *Result) {
if !r.Reached && !r.Warned {
return
}
kind := store.MarkerLimit
if r.Warned {
kind = store.MarkerWarn
}
mctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.reReadTimeout())
defer cancel()
r.MarkerErr = c.Store.MarkNotified(mctx, store.MarkerKey(r.Level, r.ID, date, r.Flow, kind))
}
// claim takes the marker after a lost reply. If that fails nothing is sent:
// a missed notification is preferred to a repeated one.
func (c *Counter) claim(ctx context.Context, date string, r *Result, kind string) bool {
won, err := c.Store.ClaimNotified(ctx, store.MarkerKey(r.Level, r.ID, date, r.Flow, kind))
if err != nil {
r.MarkerErr = err
return false
}
return won
}
func (c *Counter) abortRetryDelay() time.Duration {
if c.AbortRetryDelay > 0 {
return c.AbortRetryDelay
}
return 200 * time.Millisecond
}
func (c *Counter) reReadTimeout() time.Duration {
if c.ReReadTimeout > 0 {
return c.ReReadTimeout
+3 -3
View File
@@ -450,7 +450,7 @@ func TestPlan_OutSenderUnusableCountsRecipient(t *testing.T) {
}
func TestPlan_OutRecipientUnusableCountsSender(t *testing.T) {
job := Job{Route: RouteOut, TraceID: "u2", AuthUser: "x@example.com", LoginSuffix: "SUPPORTTOKEN", MailFrom: "x@example.com",
job := Job{Route: RouteOut, TraceID: "u2", AuthUser: "x@example.com", TokenLogin: true, MailFrom: "x@example.com",
RecipientUnusable: Unusable{Role: "recipient", Value: "u@[192.0.2.1]", Reason: "invalid e-mail address"}}
plan, err := newPlanner(false).Plan(job)
if err != nil {
@@ -461,8 +461,8 @@ func TestPlan_OutRecipientUnusableCountsSender(t *testing.T) {
{"D", "example.com", "o", "x@example.com", 2200, true},
{"O", "7", "o", "x@example.com", 2200, true},
})
if plan.LoginSuffix != "SUPPORTTOKEN" || plan.AuthUser != "x@example.com" {
t.Errorf("login suffix not carried: %+v", plan)
if !plan.TokenLogin || plan.AuthUser != "x@example.com" {
t.Errorf("token login not carried: %+v", plan)
}
if len(plan.Unusable) != 1 || plan.Unusable[0].Role != "recipient" {
t.Errorf("unusable = %+v", plan.Unusable)
+255
View File
@@ -0,0 +1,255 @@
package counting
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"migadu_counters/internal/store"
)
func TestWarnThreshold(t *testing.T) {
tests := []struct {
limit, percent, want int64
}{
{1, 80, 0}, // ceil(0.8) = 1 is the limit itself: no warning
{2, 80, 0}, // ceil(1.6) = 2 is the limit itself: no warning
{5, 80, 4},
{100, 80, 80},
{101, 80, 81},
{100, 0, 0}, // disabled
{0, 80, 0}, // no limit, no warning
{-5, 80, 0},
{1 << 62, 50, 1 << 61}, // no overflow
}
for _, tt := range tests {
if got := WarnThreshold(tt.limit, tt.percent); got != tt.want {
t.Errorf("WarnThreshold(%d, %d) = %d, want %d", tt.limit, tt.percent, got, tt.want)
}
}
}
func warnCounter(t *testing.T) (*Counter, *miniredis.Miniredis) {
t.Helper()
c, m := newCounter(t)
c.Planner.WarnPercent = 80
return c, m
}
func TestPlan_SetsWarnAt(t *testing.T) {
p := newPlanner(false)
p.WarnPercent = 80
plan, err := p.PlanOut("w", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
got := []int64{plan.Increments[0].WarnAt, plan.Increments[1].WarnAt, plan.Increments[2].WarnAt}
if got[0] != 4 || got[1] != 1760 || got[2] != 1760 {
t.Errorf("WarnAt = %v, want [4 1760 1760]", got)
}
p.WarnPercent = 0
plan, _ = p.PlanOut("w", "noreply@example.com", "", "ext@gmail.com")
if plan.Increments[0].WarnAt != 0 {
t.Errorf("WarnAt with warnings off = %d, want 0", plan.Increments[0].WarnAt)
}
}
// noreply@example.com has A/o=5, so 80% is the 4th message.
func TestCounter_WarnsOnceThenBreachesOnce(t *testing.T) {
c, _ := warnCounter(t)
ctx := context.Background()
for i := 1; i <= 7; i++ {
out, err := c.CountOut(ctx, "tr", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
a := out.Results[0]
if a.Warned != (i == 4) {
t.Errorf("message %d: warned = %v", i, a.Warned)
}
if a.Reached != (i == 5) {
t.Errorf("message %d: reached = %v", i, a.Reached)
}
}
}
// Markers must stay out of the cnt/ namespace: the web app scans cnt/*<address>*.
func TestCounter_ConfirmedCrossingWritesMarker(t *testing.T) {
c, m := warnCounter(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := c.CountOut(ctx, "tr", "noreply@example.com", "", "ext@gmail.com"); err != nil {
t.Fatal(err)
}
}
for _, kind := range []string{store.MarkerLimit, store.MarkerWarn} {
key := store.MarkerKey("A", "noreply@example.com", day, "o", kind)
if !strings.HasPrefix(key, "ntf/") {
t.Errorf("marker key %q must not share the cnt/ namespace", key)
}
if !m.Exists(key) {
t.Errorf("marker %s not written; keys = %v", key, m.Keys())
}
if ttl := m.TTL(key); ttl != store.MarkerTTL {
t.Errorf("marker %s TTL = %v, want %v", key, ttl, store.MarkerTTL)
}
}
}
// lostReplyCounter counts through a proxy that delays the next EXEC reply past
// the client's read timeout, so Redis applies the transaction but the reply is lost.
func lostReplyCounter(t *testing.T, m *miniredis.Miniredis) *Counter {
t.Helper()
proxy := delayProxy(t, m.Addr(), 1, 800*time.Millisecond)
st := store.NewWithClients(redis.NewClient(&redis.Options{Addr: proxy, ReadTimeout: 200 * time.Millisecond, PoolSize: 2}), nil)
t.Cleanup(func() { st.Close() })
p := newPlanner(false)
p.WarnPercent = 80
return &Counter{Planner: p, Store: st}
}
// A counter that already crossed and notified must stay silent on every later
// lost reply.
func TestCounter_LostReplyAfterBreachDoesNotNotifyAgain(t *testing.T) {
c, m := warnCounter(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
if _, err := c.CountOut(ctx, "tr", "noreply@example.com", "", "ext@gmail.com"); err != nil {
t.Fatal(err)
}
}
out, err := lostReplyCounter(t, m).CountOut(ctx, "lost", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
if out.Unknown == nil {
t.Fatal("reply should have been lost")
}
if a := out.Results[0]; a.Count != 6 || a.Reached || a.Warned {
t.Errorf("A = %+v, want count 6, no reached, no warned", a)
}
}
// When the crossing increment itself loses its reply it notifies exactly once,
// however many later replies are lost too.
func TestCounter_LostReplyOnCrossingNotifiesOnce(t *testing.T) {
m := miniredis.RunT(t)
key := store.Key("A", "noreply@example.com", day, "o")
m.Set(key, "4") // A/o=5: the next increment crosses
ctx := context.Background()
reached := 0
for i := 0; i < 2; i++ {
out, err := lostReplyCounter(t, m).CountOut(ctx, "lost", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
if out.Unknown == nil {
t.Fatalf("round %d: reply should have been lost", i)
}
if out.Results[0].Reached {
reached++
}
}
if reached != 1 {
t.Errorf("reached %d times over two lost replies, want 1", reached)
}
}
func TestCounter_LostReplyWarnsOnce(t *testing.T) {
m := miniredis.RunT(t)
key := store.Key("A", "noreply@example.com", day, "o")
m.Set(key, "3") // 80% of A/o=5 is 4: the next increment warns
ctx := context.Background()
out, err := lostReplyCounter(t, m).CountOut(ctx, "lost", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
if a := out.Results[0]; a.Count != 4 || !a.Warned || a.Reached {
t.Errorf("A = %+v, want count 4 warned", a)
}
m.Set(key, "3") // second lost reply lands on the same value
out, err = lostReplyCounter(t, m).CountOut(ctx, "lost", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatal(err)
}
if a := out.Results[0]; a.Warned {
t.Errorf("A = %+v, must not warn twice", a)
}
}
// Crossed says a limit of 0 never fires; the lost-reply path must agree.
func TestCounter_LostReplyZeroLimitNeverFires(t *testing.T) {
m := miniredis.RunT(t)
c := lostReplyCounter(t, m)
plan := &Plan{Route: RouteOut, Date: day, Increments: []Increment{
{Level: "A", ID: "x@example.com", Flow: "o", Key: store.Key("A", "x@example.com", day, "o"), Limited: true, Limit: 0},
}}
out, err := c.Execute(context.Background(), plan)
if err != nil {
t.Fatal(err)
}
if out.Unknown == nil {
t.Fatal("reply should have been lost")
}
if out.Results[0].Reached {
t.Errorf("limit 0 fired after a lost reply: %+v", out.Results[0])
}
}
// A transaction Redis aborted (READONLY during a failover) applied nothing, so
// it is safe to run it once more.
func TestCounter_AbortedTransactionIsRetried(t *testing.T) {
c, m := newCounter(t)
c.AbortRetryDelay = 150 * time.Millisecond
m.SetError("READONLY You can't write against a read only replica.")
go func() {
time.Sleep(30 * time.Millisecond)
m.SetError("")
}()
out, err := c.CountOut(context.Background(), "fo", "noreply@example.com", "", "ext@gmail.com")
if err != nil {
t.Fatalf("retry should have counted: %v", err)
}
if out.Unknown != nil || out.Results[0].Err != nil || out.Results[0].Count != 1 {
t.Errorf("outcome = %+v unknown=%v", out.Results[0], out.Unknown)
}
if v, _ := m.Get(store.Key("A", "noreply@example.com", day, "o")); v != "1" {
t.Errorf("redis A/o = %q, want exactly 1", v)
}
}
func TestCounter_AbortedTwiceIsAWholeFailure(t *testing.T) {
c, m := newCounter(t)
c.AbortRetryDelay = time.Millisecond
m.SetError("READONLY You can't write against a read only replica.")
out, err := c.CountOut(context.Background(), "fo", "noreply@example.com", "", "ext@gmail.com")
if err == nil || !errors.Is(err, store.ErrNotApplied) {
t.Fatalf("err = %v, outcome = %+v; want ErrNotApplied", err, out)
}
m.SetError("")
if keys := m.Keys(); len(keys) != 0 {
t.Errorf("nothing should be written, keys = %v", keys)
}
}
// A job that waited in the queue across midnight UTC counts on the day it arrived.
func TestPlan_DayIsWhenTheRequestArrived(t *testing.T) {
p := newPlanner(false)
received := fixedNow.Add(-24 * time.Hour)
plan, err := p.Plan(Job{Route: RouteIn, Recipient: "y@example.com", Received: received})
if err != nil {
t.Fatal(err)
}
if want := store.DayKey(received); plan.Date != want {
t.Errorf("plan date = %s, want %s", plan.Date, want)
}
plan, _ = p.Plan(Job{Route: RouteIn, Recipient: "y@example.com"})
if plan.Date != day {
t.Errorf("plan date without Received = %s, want %s", plan.Date, day)
}
}
+45 -1
View File
@@ -15,8 +15,10 @@
package domains
import (
"encoding/binary"
"errors"
"fmt"
"os"
"sort"
"strconv"
"strings"
@@ -83,6 +85,9 @@ func ParseLimits(s string) Limits {
if k == "" {
continue
}
if strings.Contains(k, "@") { // a local part; lookups use the lowercased mailbox
k = strings.ToLower(k)
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
continue
@@ -173,11 +178,23 @@ func Open(path string) (*DB, error) { return OpenWithCache(path, DefaultMaxCache
// OpenWithCache opens the CDB at path and counts its records (a few
// milliseconds for ~100k records; the count feeds the cdb_domains gauge and
// the reload log). maxCached bounds the parsed-record cache; 0 disables it.
func OpenWithCache(path string, maxCached int) (*DB, error) {
//
// A corrupt file is an error, never a panic: reloads run on a goroutine with
// no recover, and the cdb library panics on record lengths that overflow.
func OpenWithCache(path string, maxCached int) (db *DB, err error) {
if err := checkTables(path); err != nil {
return nil, err
}
c, err := cdb.Open(path)
if err != nil {
return nil, fmt.Errorf("open domains db %s: %w", path, err)
}
defer func() {
if p := recover(); p != nil {
c.Close()
db, err = nil, fmt.Errorf("read domains db %s: corrupt file: %v", path, p)
}
}()
n := 0
it := c.Iter()
for it.Next() {
@@ -193,6 +210,33 @@ func OpenWithCache(path string, maxCached int) (*DB, error) {
return &DB{path: path, db: c, count: n, maxCached: maxCached}, nil
}
// checkTables verifies that every hash table named in the CDB header lies
// inside the file. Walking the records never reads these tables, so a file cut
// off inside them would load and then fail lookups.
func checkTables(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open domains db %s: %w", path, err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return fmt.Errorf("open domains db %s: %w", path, err)
}
header := make([]byte, 256*8)
if _, err := f.ReadAt(header, 0); err != nil {
return fmt.Errorf("read domains db %s: header: %w", path, err)
}
for i := 0; i < 256; i++ {
offset := int64(binary.LittleEndian.Uint32(header[i*8:]))
slots := int64(binary.LittleEndian.Uint32(header[i*8+4:]))
if offset+8*slots > fi.Size() {
return fmt.Errorf("read domains db %s: hash table %d ends past the end of the file", path, i)
}
}
return nil
}
// Lookup returns the record for domain (trimmed and lowercased first), or
// ErrNotFound when the domain is not hosted here. The returned *Domain may
// be shared with other callers and must be treated as read-only.
+58
View File
@@ -1,7 +1,9 @@
package domains
import (
"encoding/binary"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -387,3 +389,59 @@ func TestMailbox(t *testing.T) {
}
}
}
func TestParseLimits_OverrideKeysLowercased(t *testing.T) {
l := ParseLimits("A/o=20,Info@/o=5,O/o=9")
if n, ok := l.Limit(AddressLevel, FlowOut, "info"); !ok || n != 5 {
t.Errorf("override for info = %d, %v; want 5", n, ok)
}
if n, ok := l.Limit(OrganizationLevel, FlowOut, ""); !ok || n != 9 {
t.Errorf("O/o = %d, %v; level keys must keep their case", n, ok)
}
}
// A file cut off inside its hash tables passes a walk over the records, but
// lookups would then fail: it must be refused at open.
func TestOpen_RejectsTruncatedHashTables(t *testing.T) {
path := filepath.Join(t.TempDir(), "t.cdb")
w, err := cdb.Create(path)
if err != nil {
t.Fatal(err)
}
for _, k := range []string{"a.example", "b.example", "c.example", "d.example"} {
if err := w.Put([]byte(k), []byte("1|A/o=1")); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if err := os.Truncate(path, fi.Size()-8); err != nil {
t.Fatal(err)
}
if db, err := Open(path); err == nil {
db.Close()
t.Fatal("truncated CDB was accepted")
}
}
// Lengths that overflow uint32 make the cdb library panic; a bad file must be
// an error, because reloads run on a goroutine with no recover.
func TestOpen_CorruptLengthsAreAnError(t *testing.T) {
buf := make([]byte, 2048+32)
binary.LittleEndian.PutUint32(buf[0:], 2048+32) // table 0 starts after one bogus record
binary.LittleEndian.PutUint32(buf[2048:], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(buf[2052:], 2)
path := filepath.Join(t.TempDir(), "bad.cdb")
if err := os.WriteFile(path, buf, 0o600); err != nil {
t.Fatal(err)
}
if db, err := Open(path); err == nil {
db.Close()
t.Fatal("corrupt CDB was accepted")
}
}
+27 -17
View File
@@ -7,17 +7,18 @@ import (
// Metrics holds every collector; fields are exported for tests.
type Metrics struct {
Requests *prometheus.CounterVec // route, status
Duration *prometheus.HistogramVec // route
Increments *prometheus.CounterVec // level, flow
LimitReached *prometheus.CounterVec // level, flow
Notifications *prometheus.CounterVec // level, result (sent|failed|disabled)
RedisErrors *prometheus.CounterVec // op
CDBDomains prometheus.Gauge
CDBReloads *prometheus.CounterVec // result (success|failure)
Dropped *prometheus.CounterVec // route: requests refused because the counting queue was full
QueueDepth prometheus.Gauge
QueueWait prometheus.Histogram
Requests *prometheus.CounterVec // route, status
Duration *prometheus.HistogramVec // route
Increments *prometheus.CounterVec // level, flow
LimitReached *prometheus.CounterVec // level, flow
WarningReached *prometheus.CounterVec // level, flow
Notifications *prometheus.CounterVec // kind (limit|warning), level, result (sent|failed|disabled)
RedisErrors *prometheus.CounterVec // op
CDBDomains prometheus.Gauge
CDBReloads *prometheus.CounterVec // result (success|failure)
Dropped *prometheus.CounterVec // route: requests refused because the counting queue was full
QueueDepth prometheus.Gauge
QueueWait prometheus.Histogram
}
// New creates the collectors and registers them with reg.
@@ -40,13 +41,17 @@ func New(reg prometheus.Registerer) *Metrics {
Name: "migadu_counters_limit_reached_total",
Help: "Increments that crossed a configured limit, by level and flow.",
}, []string{"level", "flow"}),
WarningReached: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "migadu_counters_warning_reached_total",
Help: "Increments that crossed the early-warning threshold, by level and flow.",
}, []string{"level", "flow"}),
Notifications: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "migadu_counters_notifications_total",
Help: "Web app notifications by level and result (sent, failed, disabled).",
}, []string{"level", "result"}),
Help: "Web app notifications by kind (limit, warning), level and result (sent, failed, disabled).",
}, []string{"kind", "level", "result"}),
RedisErrors: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "migadu_counters_redis_errors_total",
Help: "Failed Redis operations by op (incr, mget, ping).",
Help: "Failed Redis operations by op (incr, incr_unknown, mget, ping, marker).",
}, []string{"op"}),
CDBDomains: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "migadu_counters_cdb_domains",
@@ -70,7 +75,7 @@ func New(reg prometheus.Registerer) *Metrics {
Buckets: []float64{.0001, .0005, .001, .0025, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
}),
}
reg.MustRegister(m.Requests, m.Duration, m.Increments, m.LimitReached, m.Notifications, m.RedisErrors, m.CDBDomains, m.CDBReloads,
reg.MustRegister(m.Requests, m.Duration, m.Increments, m.LimitReached, m.WarningReached, m.Notifications, m.RedisErrors, m.CDBDomains, m.CDBReloads,
m.Dropped, m.QueueDepth, m.QueueWait)
return m
}
@@ -101,8 +106,13 @@ func (m *Metrics) RecordLimitReached(level, flow string) {
}
// RecordNotification counts one notification outcome.
func (m *Metrics) RecordNotification(level, result string) {
m.Notifications.WithLabelValues(level, result).Inc()
func (m *Metrics) RecordNotification(kind, level, result string) {
m.Notifications.WithLabelValues(kind, level, result).Inc()
}
// RecordWarningReached counts one early-warning crossing.
func (m *Metrics) RecordWarningReached(level, flow string) {
m.WarningReached.WithLabelValues(level, flow).Inc()
}
// RecordRedisError counts one failed Redis operation.
+4 -4
View File
@@ -18,8 +18,8 @@ func TestMetrics(t *testing.T) {
m.RecordIncrement("A", "o")
m.RecordIncrement("O", "i")
m.RecordLimitReached("O", "o")
m.RecordNotification("O", "sent")
m.RecordNotification("A", "failed")
m.RecordNotification("limit", "O", "sent")
m.RecordNotification("warning", "A", "failed")
m.RecordRedisError("incr")
m.SetCDBDomains(93549)
m.RecordReload(true)
@@ -40,8 +40,8 @@ func TestMetrics(t *testing.T) {
{"increments A o", m.Increments.WithLabelValues("A", "o"), 2},
{"increments O i", m.Increments.WithLabelValues("O", "i"), 1},
{"limit reached O o", m.LimitReached.WithLabelValues("O", "o"), 1},
{"notifications O sent", m.Notifications.WithLabelValues("O", "sent"), 1},
{"notifications A failed", m.Notifications.WithLabelValues("A", "failed"), 1},
{"notifications O sent", m.Notifications.WithLabelValues("limit", "O", "sent"), 1},
{"notifications A failed", m.Notifications.WithLabelValues("warning", "A", "failed"), 1},
{"redis errors incr", m.RedisErrors.WithLabelValues("incr"), 1},
{"cdb domains", m.CDBDomains, 93549},
{"reload success", m.CDBReloads.WithLabelValues("success"), 1},
+110 -40
View File
@@ -12,55 +12,85 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// Event kinds: a limit breach, or the early warning before it.
const (
KindLimit = "limit"
KindWarning = "warning"
)
// Config holds the notification settings.
type Config struct {
OrgURL string // $LEVEL=O
AddressURL string // $LEVEL=A
DomainURL string // $LEVEL=D
User string // basic auth; empty = no Authorization header
Password string
Timeout time.Duration // per attempt (default 5s)
// Early-warning URLs, per level. They must not be the breach URLs: the
// web app treats every call to those as a breach.
WarnOrgURL string
WarnAddressURL string
WarnDomainURL string
User string // basic auth; empty = no Authorization header
Password string
Timeout time.Duration // per attempt (default 15s)
Attempts int // total attempts (default 5)
Backoff time.Duration // first retry delay, doubled each retry (default 1s)
MaxConcurrent int // notifications in flight (default 8)
}
// URLFor returns the template for a level, "" when that level is disabled.
func (c Config) URLFor(level string) string {
// URLFor returns the template for a kind and level, "" when disabled.
func (c Config) URLFor(kind, level string) string {
warn := kind == KindWarning
switch level {
case "O":
if warn {
return c.WarnOrgURL
}
return c.OrgURL
case "A":
if warn {
return c.WarnAddressURL
}
return c.AddressURL
case "D":
if warn {
return c.WarnDomainURL
}
return c.DomainURL
}
return ""
}
// URLName returns the config key that serves a level, for log lines.
func URLName(level string) string {
// URLName returns the config key that serves a kind and level, for log lines.
func URLName(kind, level string) string {
prefix := ""
if kind == KindWarning {
prefix = "warn_"
}
switch level {
case "O":
return "org_url"
return prefix + "org_url"
case "A":
return "address_url"
return prefix + "address_url"
case "D":
return "domain_url"
return prefix + "domain_url"
}
return ""
}
// Event is one "limit reached" occurrence.
// Event is one "limit reached" or early-warning occurrence.
type Event struct {
Kind string // KindLimit (default) or KindWarning
TraceID string
Route string // in / out, for log correlation
Level string // A, D or O
@@ -69,6 +99,15 @@ type Event struct {
Address string // the address that triggered it ($ADDRESS / by=)
Count int64 // counter value after the increment ($COUNT)
Limit int64 // configured limit ($LIMIT)
Percent int64 // warning threshold in percent ($PERCENT)
}
// KindName returns the event's kind, defaulting to a limit breach.
func (e Event) KindName() string {
if e.Kind == "" {
return KindLimit
}
return e.Kind
}
// BuildURL fills the template placeholders with URL-escaped event values.
@@ -81,6 +120,7 @@ func BuildURL(tmpl string, ev Event) string {
"$ID", esc(ev.ID),
"$FLOW", esc(ev.Flow),
"$PERIOD", "d",
"$PERCENT", strconv.FormatInt(ev.Percent, 10),
"$ADDRESS", esc(ev.Address),
"$COUNT", strconv.FormatInt(ev.Count, 10),
).Replace(tmpl)
@@ -102,14 +142,15 @@ type Notifier struct {
wg sync.WaitGroup
quit chan struct{}
mu sync.Mutex
closed bool
mu sync.Mutex
closed bool
quitOnce sync.Once
}
// New applies defaults and returns a ready Notifier.
func New(cfg Config, hooks Hooks) *Notifier {
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
cfg.Timeout = 15 * time.Second
}
if cfg.Attempts <= 0 {
cfg.Attempts = 5
@@ -132,7 +173,7 @@ func New(cfg Config, hooks Hooks) *Notifier {
// Notify dispatches ev in the background. It returns false, without doing
// anything, when no URL is configured for the level or after Shutdown.
func (n *Notifier) Notify(ev Event) bool {
tmpl := n.cfg.URLFor(ev.Level)
tmpl := n.cfg.URLFor(ev.KindName(), ev.Level)
if tmpl == "" {
return false
}
@@ -147,57 +188,87 @@ func (n *Notifier) Notify(ev Event) bool {
u := BuildURL(tmpl, ev)
go func() {
defer n.wg.Done()
n.sem <- struct{}{}
defer func() { <-n.sem }()
n.send(ev, u)
}()
return true
}
// send delivers ev, holding a concurrency slot only while a request is in
// flight, never during a backoff.
func (n *Notifier) send(ev Event, u string) {
var lastErr error
for attempt := 1; attempt <= n.cfg.Attempts; attempt++ {
attempts := 0
for attempts < n.cfg.Attempts {
if attempts > 0 && !n.wait(n.cfg.Backoff<<(attempts-1)) {
lastErr = fmt.Errorf("shutting down after attempt %d: %w", attempts, lastErr)
break
}
select {
case n.sem <- struct{}{}:
case <-n.quit:
n.failed(ev, u, attempts, errors.New("shutting down before the request was sent"))
return
}
err, retry := n.attempt(u)
<-n.sem
attempts++
if err == nil {
if n.hooks.Sent != nil {
n.hooks.Sent(ev, u, attempt)
n.hooks.Sent(ev, u, attempts)
}
return
}
lastErr = err
if !retry {
if n.hooks.Failed != nil {
n.hooks.Failed(ev, u, attempt, err)
}
return
}
if attempt < n.cfg.Attempts {
select {
case <-time.After(n.cfg.Backoff << (attempt - 1)):
case <-n.quit:
lastErr = fmt.Errorf("shutting down after attempt %d: %w", attempt, err)
attempt = n.cfg.Attempts // stop retrying
}
break
}
}
n.failed(ev, u, attempts, lastErr)
}
func (n *Notifier) failed(ev Event, u string, attempts int, err error) {
if n.hooks.Failed != nil {
n.hooks.Failed(ev, u, n.cfg.Attempts, lastErr)
n.hooks.Failed(ev, u, attempts, err)
}
}
// attempt performs one GET. retry is false for 4xx: wrong credentials or a
// wrong URL will not fix themselves.
// wait sleeps for d and reports false when the shutdown deadline passed first.
func (n *Notifier) wait(d time.Duration) bool {
select {
case <-time.After(d):
return true
case <-n.quit:
return false
}
}
// attempt performs one GET. retry is true only when a repeat cannot duplicate
// the notification or the web app said it failed: the request never left, or
// the answer was 429 or 5xx. A request that was sent but got no answer may
// have been acted on, and 4xx (wrong credentials, wrong URL) will not fix
// itself.
func (n *Notifier) attempt(u string) (err error, retry bool) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return err, false
}
var wrote atomic.Bool
req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
WroteRequest: func(info httptrace.WroteRequestInfo) {
if info.Err == nil {
wrote.Store(true)
}
},
}))
if n.cfg.User != "" {
req.SetBasicAuth(n.cfg.User, n.cfg.Password)
}
req.Header.Set("User-Agent", "migadu_counters")
resp, err := n.client.Do(req)
if err != nil {
if wrote.Load() {
return fmt.Errorf("outcome unknown, request was sent: %w", err), false
}
return err, true
}
defer resp.Body.Close()
@@ -212,14 +283,12 @@ func (n *Notifier) attempt(u string) (err error, retry bool) {
}
}
// Shutdown refuses new events, stops pending retries and waits for in-flight
// requests until ctx expires. Safe to call more than once.
// Shutdown refuses new events and waits for in-flight notifications and their
// pending retries until ctx expires; then remaining retries are abandoned.
// Safe to call more than once.
func (n *Notifier) Shutdown(ctx context.Context) error {
n.mu.Lock()
if !n.closed {
n.closed = true
close(n.quit)
}
n.closed = true
n.mu.Unlock()
done := make(chan struct{})
@@ -231,6 +300,7 @@ func (n *Notifier) Shutdown(ctx context.Context) error {
case <-done:
return nil
case <-ctx.Done():
n.quitOnce.Do(func() { close(n.quit) })
return errors.Join(ctx.Err(), errors.New("notifications still in flight"))
}
}
+139 -8
View File
@@ -36,10 +36,15 @@ func TestBuildURL(t *testing.T) {
}
func TestURLFor(t *testing.T) {
cfg := Config{OrgURL: "o", AddressURL: "a", DomainURL: "d"}
cfg := Config{OrgURL: "o", AddressURL: "a", DomainURL: "d", WarnOrgURL: "wo", WarnAddressURL: "wa", WarnDomainURL: "wd"}
for level, want := range map[string]string{"O": "o", "A": "a", "D": "d", "X": ""} {
if got := cfg.URLFor(level); got != want {
t.Errorf("URLFor(%s) = %q, want %q", level, got, want)
if got := cfg.URLFor(KindLimit, level); got != want {
t.Errorf("URLFor(limit, %s) = %q, want %q", level, got, want)
}
}
for level, want := range map[string]string{"O": "wo", "A": "wa", "D": "wd", "X": ""} {
if got := cfg.URLFor(KindWarning, level); got != want {
t.Errorf("URLFor(warning, %s) = %q, want %q", level, got, want)
}
}
}
@@ -260,20 +265,146 @@ func TestNotify_NoRetryOn4xx(t *testing.T) {
}
}
func TestNotify_TimeoutIsRetried(t *testing.T) {
// The request reached the web app and only the answer is missing: it may have
// acted, so a retry could send the customer a second email.
func TestNotify_TimeoutAfterWriteIsNotRetried(t *testing.T) {
c := &capture{delay: 300 * time.Millisecond}
s := httptest.NewServer(c)
defer s.Close()
h := newHooks()
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
cfg.Timeout = 50 * time.Millisecond
cfg.Attempts = 2
n := New(cfg, h.Hooks())
n.Notify(Event{Level: "O", ID: "7"})
h.wait(t, 1)
n.Shutdown(context.Background())
if len(h.failed) != 1 || h.failed[0].attempts != 2 {
t.Errorf("failed=%+v; want failure after 2 timed-out attempts", h.failed)
if len(h.failed) != 1 || h.failed[0].attempts != 1 {
t.Fatalf("failed=%+v; want one failure after a single attempt", h.failed)
}
if !strings.Contains(h.failed[0].err.Error(), "outcome unknown") {
t.Errorf("error should say the outcome is unknown: %v", h.failed[0].err)
}
waitCalls(t, c, 1)
time.Sleep(100 * time.Millisecond)
if c.calls.Load() != 1 {
t.Errorf("web app calls = %d, want 1", c.calls.Load())
}
}
// Nothing was delivered, so retrying cannot duplicate anything.
func TestNotify_RefusedConnectionIsRetried(t *testing.T) {
s := httptest.NewServer(&capture{})
addr := s.URL
s.Close()
h := newHooks()
cfg := fastConfig(addr+"/l?level=$LEVEL", "", "")
cfg.Attempts = 3
n := New(cfg, h.Hooks())
n.Notify(Event{Level: "O", ID: "7"})
h.wait(t, 1)
n.Shutdown(context.Background())
if len(h.failed) != 1 || h.failed[0].attempts != 3 {
t.Errorf("failed=%+v; want failure after 3 attempts", h.failed)
}
}
// A notification waiting out its backoff must not hold a concurrency slot.
func TestNotify_BackoffReleasesSlot(t *testing.T) {
c := &capture{status: []int{500, 200, 200}}
s := httptest.NewServer(c)
defer s.Close()
h := newHooks()
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
cfg.MaxConcurrent = 1
cfg.Backoff = 400 * time.Millisecond
n := New(cfg, h.Hooks())
defer n.Shutdown(context.Background())
n.Notify(Event{Level: "O", ID: "retrying"})
waitCalls(t, c, 1)
n.Notify(Event{Level: "O", ID: "second"})
select {
case <-h.done:
case <-time.After(250 * time.Millisecond):
t.Fatal("the second notification waited for the first one's backoff")
}
h.done <- struct{}{}
h.wait(t, 2)
}
func waitCalls(t *testing.T, c *capture, n int32) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for c.calls.Load() < n {
if time.Now().After(deadline) {
t.Fatalf("calls = %d, want %d", c.calls.Load(), n)
}
time.Sleep(time.Millisecond)
}
}
// Shutdown lets a pending retry finish while its deadline allows.
func TestNotify_ShutdownFinishesPendingRetries(t *testing.T) {
c := &capture{status: []int{500, 200}}
s := httptest.NewServer(c)
defer s.Close()
h := newHooks()
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
cfg.Backoff = 100 * time.Millisecond
n := New(cfg, h.Hooks())
n.Notify(Event{Level: "O", ID: "7"})
waitCalls(t, c, 1)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := n.Shutdown(ctx); err != nil {
t.Fatal(err)
}
if len(h.sent) != 1 || h.sent[0].attempt != 2 || len(h.failed) != 0 {
t.Errorf("sent=%+v failed=%+v; want delivery on attempt 2", h.sent, h.failed)
}
}
// When the deadline cuts a retry short the failure reports the real attempt count.
func TestNotify_ShutdownDeadlineReportsRealAttempts(t *testing.T) {
c := &capture{status: []int{500}}
s := httptest.NewServer(c)
defer s.Close()
h := newHooks()
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
cfg.Backoff = 5 * time.Second
n := New(cfg, h.Hooks())
n.Notify(Event{Level: "O", ID: "7"})
waitCalls(t, c, 1)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
_ = n.Shutdown(ctx)
h.wait(t, 1)
if len(h.failed) != 1 || h.failed[0].attempts != 1 {
t.Errorf("failed=%+v; want 1 real attempt", h.failed)
}
}
func TestNotify_WarningUsesWarningURL(t *testing.T) {
breach, warn := &capture{}, &capture{}
sb, sw := httptest.NewServer(breach), httptest.NewServer(warn)
defer sb.Close()
defer sw.Close()
h := newHooks()
cfg := fastConfig(sb.URL+"/l?level=$LEVEL", "", "")
cfg.WarnOrgURL = sw.URL + "/w?level=$LEVEL&limit=$LIMIT&percent=$PERCENT"
n := New(cfg, h.Hooks())
defer n.Shutdown(context.Background())
if !n.Notify(Event{Kind: KindWarning, Level: "O", ID: "7", Limit: 100, Count: 80, Percent: 80}) {
t.Fatal("warning refused")
}
h.wait(t, 1)
if breach.calls.Load() != 0 || warn.calls.Load() != 1 {
t.Fatalf("breach=%d warn=%d, want the warning URL only", breach.calls.Load(), warn.calls.Load())
}
if q := warn.requests[0].URL.Query(); q.Get("percent") != "80" || q.Get("limit") != "100" {
t.Errorf("query = %v", q)
}
if n.Notify(Event{Kind: KindWarning, Level: "A"}) {
t.Error("a level with no warning URL must be refused")
}
}
@@ -326,7 +457,7 @@ func TestNotify_ShutdownDrainsAndRespectsContext(t *testing.T) {
func TestNew_Defaults(t *testing.T) {
n := New(Config{OrgURL: "https://x/?level=$LEVEL"}, Hooks{})
defer n.Shutdown(context.Background())
if n.cfg.Attempts != 5 || n.cfg.Backoff != time.Second || n.cfg.MaxConcurrent != 8 || n.cfg.Timeout != 5*time.Second {
if n.cfg.Attempts != 5 || n.cfg.Backoff != time.Second || n.cfg.MaxConcurrent != 8 || n.cfg.Timeout != 15*time.Second {
t.Errorf("defaults not applied: %+v", n.cfg)
}
}
+11
View File
@@ -48,6 +48,17 @@ func (s *Server) reject(w http.ResponseWriter, r *http.Request, route string, e
s.d.Logger.Warn("request rejected", "route", route, "remote", r.RemoteAddr, "status", e.status, "reason", e.msg)
}
// rejectBody is reject for a request whose body was read: the trace id, when
// there is one, ties the rejection to the message, in both logs.
func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request, route, traceID string, e *httpError) {
writeError(w, e.status, e.msg)
id := api.SanitizeTraceID(traceID)
s.d.Logger.Warn("request rejected", "route", route, "trace_id", id, "remote", r.RemoteAddr, "status", e.status, "reason", e.msg)
if s.d.TxLog != nil {
s.d.TxLog.Rejected(strings.TrimPrefix(route, "/"), id, e.status, api.SanitizeForLog(e.msg))
}
}
// statusWriter captures the status code for metrics.
type statusWriter struct {
http.ResponseWriter
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"context"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"migadu_counters/internal/store"
)
// Real CDB: yagolivas.com has A/o=20 and O/o=20, so 80% is 16.
func TestOut_WarnsAt80PercentOnceAndBreachesOnce(t *testing.T) {
h := newHarness(t, options{warnPercent: 80})
for i := 0; i < 22; i++ {
h.accept("/out", `{"trace_id":"w","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
}
if err := h.notifier.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
if h.warnAddr.count() != 1 || h.warnOrg.count() != 1 {
t.Errorf("warnings addr=%d org=%d, want 1 each", h.warnAddr.count(), h.warnOrg.count())
}
if h.addrURL.count() != 1 || h.orgURL.count() != 1 {
t.Errorf("breaches addr=%d org=%d, want 1 each", h.addrURL.count(), h.orgURL.count())
}
u, err := url.Parse(h.warnAddr.urls[0])
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Path != "/api/limits/warning" || q.Get("count") != "16" || q.Get("limit") != "20" || q.Get("percent") != "80" || q.Get("id") != "a@yagolivas.com" {
t.Errorf("warning url = %s", h.warnAddr.urls[0])
}
if h.metric(h.m.WarningReached.WithLabelValues("A", "o")) != 1 {
t.Error("warning_reached_total not recorded")
}
if h.metric(h.m.Notifications.WithLabelValues("warning", "A", "sent")) != 1 {
t.Error("notifications_total{warning,A,sent} not recorded")
}
if !strings.Contains(h.tx.String(), "warning reached (level=[A] id=[a@yagolivas.com] flow=[o] by=[a@yagolivas.com]) => notifying warn_address_url") {
t.Errorf("transaction log:\n%s", h.tx.String())
}
}
func TestOut_ConcurrentIncrementsNotifyOnce(t *testing.T) {
h := newHarness(t, options{warnPercent: 80, workers: 8})
var wg sync.WaitGroup
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
h.post("/out", `{"trace_id":"c","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
}()
}
wg.Wait()
h.settle()
if err := h.notifier.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
if h.redisInt(store.Key("A", "a@yagolivas.com", day, "o")) != 40 {
t.Fatalf("counter = %d, want 40", h.redisInt(store.Key("A", "a@yagolivas.com", day, "o")))
}
if h.warnAddr.count() != 1 || h.addrURL.count() != 1 {
t.Errorf("warnings=%d breaches=%d, want 1 each", h.warnAddr.count(), h.addrURL.count())
}
}
func TestOut_WarningsOffSendsNone(t *testing.T) {
h := newHarness(t, options{})
for i := 0; i < 20; i++ {
h.accept("/out", `{"trace_id":"w","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
}
h.notifier.Shutdown(context.Background())
if h.warnAddr.count() != 0 || h.addrURL.count() != 1 {
t.Errorf("warnings=%d breaches=%d, want 0 and 1", h.warnAddr.count(), h.addrURL.count())
}
if strings.Contains(h.tx.String(), "warning reached") {
t.Errorf("transaction log mentions a warning:\n%s", h.tx.String())
}
}
// Once a breach was notified, a lost EXEC reply on a later message sends nothing.
func TestLostReplyAfterBreachSendsNothing(t *testing.T) {
backend := miniredis.RunT(t)
proxy := delayProxy(t, backend.Addr(), 3*time.Second)
h := newHarness(t, options{writeAddr: proxy})
key := store.Key("A", "a@yagolivas.com", day, "o") // A/o=20
backend.Set(key, "40")
backend.Set(store.MarkerKey("A", "a@yagolivas.com", day, "o", store.MarkerLimit), "1")
h.accept("/out", `{"trace_id":"lost2","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}`)
if err := h.notifier.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
if h.addrURL.count() != 0 {
t.Errorf("address notifications = %d, want 0", h.addrURL.count())
}
if !strings.Contains(h.tx.String(), "count=41 limit=20 reached=false (re-read)") {
t.Errorf("transaction log:\n%s", h.tx.String())
}
}
// --- review round 2 ------------------------------------------------------------
func TestOut_LoginSuffixNeverLogged(t *testing.T) {
h := newHarness(t, options{})
h.accept("/out", `{"trace_id":"m2","auth_user":"x@yagolivas.com@SECRET\\nFORGED LINE","recipient":"y@yagolivas.com"}`)
h.accept("/out", `{"trace_id":"m3","auth_user":"x@yagolivas.com@SECRET\nFORGED LINE","recipient":"y@yagolivas.com"}`)
if tx := h.tx.String(); strings.Contains(tx, "SECRET") || strings.Contains(tx, "FORGED") {
t.Errorf("transaction log contains the login suffix:\n%s", tx)
}
}
func TestReject_LeavesTraceInBothLogs(t *testing.T) {
h := newHarness(t, options{})
if st, _ := h.post("/in", `{"trace_id":"TRACE-XYZ","recipient":"nobody"}`); st != 400 {
t.Fatal(st)
}
if !strings.Contains(h.log.String(), "TRACE-XYZ") {
t.Errorf("app log lacks the trace id:\n%s", h.log.String())
}
if !strings.Contains(h.tx.String(), "<TRACE-XYZ> in; rejected: 400 ") {
t.Errorf("counting log lacks the rejection:\n%s", h.tx.String())
}
}
func TestErrors_DoNotExposeInternals(t *testing.T) {
h := newHarness(t, options{})
readAddr, writeAddr := h.read.Addr(), h.write.Addr()
h.read.Close()
st, body := h.get("/counters?address=a@yagolivas.com")
if st != 503 || strings.Contains(body, readAddr) || strings.Contains(body, "dial") {
t.Errorf("/counters = %d %s", st, body)
}
if !strings.Contains(h.log.String(), readAddr) {
t.Errorf("detail should stay in the app log:\n%s", h.log.String())
}
h.write.Close()
st, body = h.adminGet("/health")
if st != 503 || strings.Contains(body, writeAddr) || strings.Contains(body, "dial") {
t.Errorf("/health = %d %s", st, body)
}
}
func TestPanic_CountedInRequestMetrics(t *testing.T) {
h := newHarness(t, options{})
h.srv.panicHook = func() { panic("boom") }
if st, _ := h.post("/in", `{"recipient":"b@yagolivas.com"}`); st != 500 {
t.Fatal(st)
}
if got := h.metric(h.m.Requests.WithLabelValues("/in", "500")); got != 1 {
t.Errorf("requests_total{/in,500} = %v, want 1", got)
}
}
// Real CDB: xn-- domains are hosted under their punycode key.
func TestCounters_UnicodeDomainIsNormalised(t *testing.T) {
h := newHarness(t, options{})
if st, _ := h.get("/counters?domain=host_name.example.com"); st != 400 {
t.Errorf("invalid domain status = %d, want 400", st)
}
st, body := h.get("/counters?domain=" + url.QueryEscape("Bücher.example."))
if st != 200 {
t.Fatalf("status %d: %s", st, body)
}
if r := decodeCounters(t, body); r.Query != "xn--bcher-kva.example" {
t.Errorf("query = %q, want the punycode form", r.Query)
}
}
+48 -20
View File
@@ -92,7 +92,7 @@ func NotifyHooks(tx *txlog.Logger, m *metrics.Metrics) notify.Hooks {
return notify.Hooks{
Sent: func(ev notify.Event, url string, attempt int) {
if m != nil {
m.RecordNotification(ev.Level, "sent")
m.RecordNotification(ev.KindName(), ev.Level, "sent")
}
if tx != nil {
tx.NotificationSent(ev, url, attempt)
@@ -100,7 +100,7 @@ func NotifyHooks(tx *txlog.Logger, m *metrics.Metrics) notify.Hooks {
},
Failed: func(ev notify.Event, url string, attempts int, err error) {
if m != nil {
m.RecordNotification(ev.Level, "failed")
m.RecordNotification(ev.KindName(), ev.Level, "failed")
}
if tx != nil {
tx.NotificationFailed(ev, url, attempts, err)
@@ -109,7 +109,7 @@ func NotifyHooks(tx *txlog.Logger, m *metrics.Metrics) notify.Hooks {
}
}
// Handler returns the API handler: recover → metrics → timeout → routes.
// Handler returns the API handler: metrics → recover → timeout → routes.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/out", s.route("/out", http.MethodPost, s.handleOut))
@@ -119,7 +119,7 @@ func (s *Server) Handler() http.Handler {
writeError(w, http.StatusNotFound, "not found")
})
timed := http.TimeoutHandler(mux, s.d.HandlerTimeout, `{"error":"request timeout"}`)
return s.recoverer(s.observe(timed))
return s.observe(s.recoverer(timed))
}
// AdminHandler returns /health and /metrics (no auth; bind admin_addr locally).
@@ -135,17 +135,17 @@ func (s *Server) AdminHandler() http.Handler {
func (s *Server) handleOut(w http.ResponseWriter, r *http.Request) {
var req api.OutRequest
if e := decodeJSON(r, &req); e != nil {
s.reject(w, r, "/out", e)
s.rejectBody(w, r, "/out", req.TraceID, e)
return
}
v, err := api.ValidateOut(req)
if err != nil {
s.reject(w, r, "/out", &httpError{http.StatusBadRequest, err.Error()})
s.rejectBody(w, r, "/out", req.TraceID, &httpError{http.StatusBadRequest, err.Error()})
return
}
job := counting.Job{
Route: counting.RouteOut, TraceID: v.TraceID, Received: s.d.Now(),
AuthUser: v.AuthUser.Value, LoginSuffix: v.LoginSuffix,
AuthUser: v.AuthUser.Value, TokenLogin: v.TokenLogin,
MailFrom: v.MailFrom, MailFromUsable: v.MailFromUsable,
Recipient: v.Recipient.Value,
}
@@ -161,12 +161,12 @@ func (s *Server) handleOut(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleIn(w http.ResponseWriter, r *http.Request) {
var req api.InRequest
if e := decodeJSON(r, &req); e != nil {
s.reject(w, r, "/in", e)
s.rejectBody(w, r, "/in", req.TraceID, e)
return
}
v, err := api.ValidateIn(req)
if err != nil {
s.reject(w, r, "/in", &httpError{http.StatusBadRequest, err.Error()})
s.rejectBody(w, r, "/in", req.TraceID, &httpError{http.StatusBadRequest, err.Error()})
return
}
s.enqueue(w, r, "/in", counting.Job{
@@ -242,16 +242,23 @@ func (s *Server) process(ctx context.Context, job counting.Job) {
if s.d.Metrics != nil {
s.d.Metrics.RecordIncrement(res.Level, res.Flow)
}
if res.Reached {
if res.MarkerErr != nil {
if s.d.Metrics != nil {
s.d.Metrics.RecordRedisError("marker")
}
s.d.Logger.Error("notification marker failed", "route", plan.Route, "trace_id", plan.TraceID, "key", res.Key, "error", res.MarkerErr)
}
switch {
case res.Reached:
if s.d.Metrics != nil {
s.d.Metrics.RecordLimitReached(res.Level, res.Flow)
}
ev := notify.Event{TraceID: plan.TraceID, Route: plan.Route, Level: res.Level, ID: res.ID,
Flow: res.Flow, Address: res.By, Count: res.Count, Limit: res.Limit}
dispatched[i] = s.d.Notifier != nil && s.d.Notifier.Notify(ev)
if !dispatched[i] && s.d.Metrics != nil {
s.d.Metrics.RecordNotification(res.Level, "disabled")
dispatched[i] = s.notify(plan, res, notify.KindLimit)
case res.Warned:
if s.d.Metrics != nil {
s.d.Metrics.RecordWarningReached(res.Level, res.Flow)
}
dispatched[i] = s.notify(plan, res, notify.KindWarning)
}
}
if s.d.TxLog != nil {
@@ -259,11 +266,22 @@ func (s *Server) process(ctx context.Context, job counting.Job) {
}
}
// notify dispatches one notification and reports whether it was handed over.
func (s *Server) notify(plan *counting.Plan, res counting.Result, kind string) bool {
ev := notify.Event{Kind: kind, TraceID: plan.TraceID, Route: plan.Route, Level: res.Level, ID: res.ID,
Flow: res.Flow, Address: res.By, Count: res.Count, Limit: res.Limit, Percent: s.d.Counter.Planner.WarnPercent}
sent := s.d.Notifier != nil && s.d.Notifier.Notify(ev)
if !sent && s.d.Metrics != nil {
s.d.Metrics.RecordNotification(kind, res.Level, "disabled")
}
return sent
}
// handleCounters answers GET /counters?address=|domain=[&date=] from the
// read side.
func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
address, domain := strings.TrimSpace(q.Get("address")), strings.ToLower(strings.TrimSpace(q.Get("domain")))
address, domain := strings.TrimSpace(q.Get("address")), strings.TrimSpace(q.Get("domain"))
if (address == "") == (domain == "") {
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "exactly one of address or domain is required"})
return
@@ -287,6 +305,12 @@ func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
at := strings.LastIndex(addr, "@")
local, domain = addr[:at], addr[at+1:]
} else {
dom, err := api.NormalizeDomain(domain)
if err != nil {
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "domain: invalid domain"})
return
}
domain = dom
resp.Query, resp.Mode = domain, "domain"
}
@@ -299,7 +323,8 @@ func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, domains.ErrMalformed) {
s.d.Logger.Warn("malformed row in domains db, domain treated as not hosted", "domain", domain, "route", "/counters", "error", err)
} else if err != nil && !errors.Is(err, domains.ErrNotFound) {
writeError(w, http.StatusServiceUnavailable, "domain lookup failed: "+err.Error())
s.d.Logger.Error("domain lookup failed", "domain", domain, "route", "/counters", "error", err)
writeError(w, http.StatusServiceUnavailable, "domain lookup failed")
return
}
managed := err == nil
@@ -326,7 +351,8 @@ func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
if s.d.Metrics != nil {
s.d.Metrics.RecordRedisError("mget")
}
writeError(w, http.StatusServiceUnavailable, "redis unavailable: "+err.Error())
s.d.Logger.Error("redis read failed", "route", "/counters", "error", err)
writeError(w, http.StatusServiceUnavailable, "redis unavailable")
return
}
limit := func(level, flow string) *int64 {
@@ -355,7 +381,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
return
}
if err := db.Ping(); err != nil {
writeError(w, http.StatusServiceUnavailable, "domains db not readable: "+err.Error())
s.d.Logger.Warn("health: domains db not readable", "error", err)
writeError(w, http.StatusServiceUnavailable, "domains db not readable")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
@@ -364,7 +391,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if s.d.Metrics != nil {
s.d.Metrics.RecordRedisError("ping")
}
writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("redis: %v", err))
s.d.Logger.Warn("health: redis not reachable", "error", err)
writeError(w, http.StatusServiceUnavailable, "redis unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+16 -5
View File
@@ -87,6 +87,7 @@ type options struct {
exempt map[string]bool
queueSize int
workers int
warnPercent int64 // > 0: warnings on, sent to warnAddr / warnOrg
}
type harness struct {
@@ -102,6 +103,8 @@ type harness struct {
addrURL *capture
orgURL *capture
domURL *capture
warnAddr *capture
warnOrg *capture
log *syncBuffer
tx *syncBuffer
reg *prometheus.Registry
@@ -154,6 +157,14 @@ func newHarness(t *testing.T, o options) *harness {
if o.domainURL {
ncfg.DomainURL = sd.URL + q
}
h.warnAddr, h.warnOrg = &capture{}, &capture{}
if o.warnPercent > 0 {
swa, swo := httptest.NewServer(h.warnAddr), httptest.NewServer(h.warnOrg)
t.Cleanup(swa.Close)
t.Cleanup(swo.Close)
wq := "/api/limits/warning?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT&limit=$LIMIT&percent=$PERCENT"
ncfg.WarnAddressURL, ncfg.WarnOrgURL = swa.URL+wq, swo.URL+wq
}
h.reg = prometheus.NewRegistry()
h.m = metrics.New(h.reg)
@@ -164,7 +175,7 @@ func newHarness(t *testing.T, o options) *harness {
logger := slog.New(slog.NewTextHandler(h.log, &slog.HandlerOptions{Level: slog.LevelDebug}))
planner := &counting.Planner{
Lookup: counting.LookupFunc(func(d string) (*domains.Domain, error) { return (*h.lookup.Load())(d) }),
Exempt: o.exempt, Now: func() time.Time { return clock },
Exempt: o.exempt, Now: func() time.Time { return clock }, WarnPercent: o.warnPercent,
}
s := New(Deps{
AuthToken: o.token,
@@ -349,8 +360,8 @@ func TestOut_ReachedNotifiesConfiguredLevelsOnce(t *testing.T) {
t.Errorf("transaction log lacks %q:\n%s", want, tx)
}
}
if h.metric(h.m.LimitReached.WithLabelValues("A", "o")) != 1 || h.metric(h.m.Notifications.WithLabelValues("A", "sent")) != 1 ||
h.metric(h.m.Notifications.WithLabelValues("D", "disabled")) != 1 {
if h.metric(h.m.LimitReached.WithLabelValues("A", "o")) != 1 || h.metric(h.m.Notifications.WithLabelValues("limit", "A", "sent")) != 1 ||
h.metric(h.m.Notifications.WithLabelValues("limit", "D", "disabled")) != 1 {
t.Error("limit/notification metrics not recorded")
}
}
@@ -569,7 +580,7 @@ func TestOut_LoginSuffixCountsBaseAddress(t *testing.T) {
if h.redisInt(store.Key("A", "x@yagolivas.com", day, "o")) != 1 || h.redisInt(store.Key("A", "y@yagolivas.com", day, "i")) != 1 {
t.Errorf("base address not counted: %v", h.write.Keys())
}
if !strings.Contains(h.tx.String(), "<m1> out; request auth_user=x@yagolivas.com recipient=y@yagolivas.com mail_from=x@yagolivas.com login_suffix=SUPPORTTOKEN") {
if !strings.Contains(h.tx.String(), "<m1> out; request auth_user=x@yagolivas.com recipient=y@yagolivas.com mail_from=x@yagolivas.com token_login=true") {
t.Errorf("transaction log:\n%s", h.tx.String())
}
}
@@ -1046,7 +1057,7 @@ func TestNotifyHooks(t *testing.T) {
ev := notify.Event{TraceID: "t", Route: "out", Level: "O"}
hooks.Sent(ev, "http://x", 1)
hooks.Failed(ev, "http://x", 5, io.EOF)
if testutil.ToFloat64(m.Notifications.WithLabelValues("O", "sent")) != 1 || testutil.ToFloat64(m.Notifications.WithLabelValues("O", "failed")) != 1 {
if testutil.ToFloat64(m.Notifications.WithLabelValues("limit", "O", "sent")) != 1 || testutil.ToFloat64(m.Notifications.WithLabelValues("limit", "O", "failed")) != 1 {
t.Error("hooks did not record metrics")
}
if s := buf.String(); !strings.Contains(s, "sent limit exceeded") || !strings.Contains(s, "failed limit exceeded") {
+132
View File
@@ -0,0 +1,132 @@
package store
import (
"context"
"errors"
"net"
"testing"
"time"
"migadu_counters/internal/config"
)
func TestMarkerKey(t *testing.T) {
got := MarkerKey("A", "x@example.com", "2026-10-13", "o", MarkerLimit)
if want := "ntf/A/x@example.com/d/2026-10-13/o/limit"; got != want {
t.Errorf("MarkerKey = %q, want %q", got, want)
}
if got := MarkerKey("O", "7", "2026-10-13", "i", MarkerWarn); got != "ntf/O/7/d/2026-10-13/i/warn" {
t.Errorf("MarkerKey = %q", got)
}
}
func TestMarkAndClaimNotified(t *testing.T) {
s, w, _ := testStore(t)
ctx := context.Background()
if err := s.MarkNotified(ctx, "ntf/a"); err != nil {
t.Fatal(err)
}
if ttl := w.TTL("ntf/a"); ttl != MarkerTTL {
t.Errorf("TTL = %v, want %v", ttl, MarkerTTL)
}
// Marking again is not an error: a reset counter may cross again.
if err := s.MarkNotified(ctx, "ntf/a"); err != nil {
t.Errorf("second mark: %v", err)
}
ok, err := s.ClaimNotified(ctx, "ntf/a")
if err != nil || ok {
t.Errorf("claim of a marked key = %v, %v; want false", ok, err)
}
ok, err = s.ClaimNotified(ctx, "ntf/b")
if err != nil || !ok {
t.Errorf("first claim = %v, %v; want true", ok, err)
}
if ttl := w.TTL("ntf/b"); ttl != MarkerTTL {
t.Errorf("claim TTL = %v, want %v", ttl, MarkerTTL)
}
ok, err = s.ClaimNotified(ctx, "ntf/b")
if err != nil || ok {
t.Errorf("second claim = %v, %v; want false", ok, err)
}
}
func TestClaimNotified_RedisDown(t *testing.T) {
s, w, _ := testStore(t)
w.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if ok, err := s.ClaimNotified(ctx, "ntf/a"); err == nil || ok {
t.Errorf("claim with redis down = %v, %v; want an error and false", ok, err)
}
}
// No connection could be had from the pool: no command was ever sent.
func TestOutcomeUnknown_PoolTimeoutIsDefinite(t *testing.T) {
if OutcomeUnknown(errors.New("redis incr: redis: connection pool timeout")) {
t.Error("a pool timeout never reached Redis and must not be an unknown outcome")
}
}
func TestOutcomeUnknown_NotAppliedIsDefinite(t *testing.T) {
if OutcomeUnknown(ErrNotApplied) {
t.Error("ErrNotApplied must not be an unknown outcome")
}
}
// Transaction-level rejection: every command failed, nothing was applied.
func TestIncr_RejectedTransactionIsNotApplied(t *testing.T) {
s, w, _ := testStore(t)
w.SetError("READONLY You can't write against a read only replica.")
defer w.SetError("")
_, err := s.Incr(context.Background(), []string{"a", "b"})
if !errors.Is(err, ErrNotApplied) {
t.Fatalf("err = %v, want ErrNotApplied", err)
}
}
// All INCRs failing on non-numbers is still per-key: the EXPIREs succeeded and
// EXEC ran, so it must not be mistaken for a rejected transaction.
func TestIncr_AllKeysNonNumericIsPerKey(t *testing.T) {
s, w, _ := testStore(t)
w.Set("a", "x")
w.Set("b", "y")
res, err := s.Incr(context.Background(), []string{"a", "b"})
if err != nil {
t.Fatalf("per-key failures must not fail the transaction: %v", err)
}
if res[0].Err == nil || res[1].Err == nil {
t.Errorf("results = %+v", res)
}
}
// A context deadline must bound socket time, not only the configured timeout.
func TestPing_HonoursContextDeadline(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() { // accepts and never answers
for {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
}
}()
s, err := New(config.RedisConfig{WriteAddr: ln.Addr().String(), PoolSize: 2, TimeoutMS: 3000})
if err != nil {
t.Fatal(err)
}
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
if err := s.Ping(ctx); err == nil {
t.Fatal("expected a timeout")
}
if d := time.Since(start); d > time.Second {
t.Errorf("Ping took %v with a 100ms deadline", d)
}
}
+55 -2
View File
@@ -32,13 +32,30 @@ const (
Prefix = "cnt"
// TTL is refreshed on every increment, like the Lua plugin did.
TTL = 31 * 24 * time.Hour
// MarkerLimit and MarkerWarn name the two notification markers of a counter.
MarkerLimit = "limit"
MarkerWarn = "warn"
// MarkerTTL outlives the UTC day the marker belongs to.
MarkerTTL = 48 * time.Hour
)
// ErrNotApplied wraps an Incr failure where Redis answered and rejected the
// whole transaction (EXECABORT, READONLY, LOADING, ...): nothing was applied,
// so running it again cannot double count.
var ErrNotApplied = errors.New("transaction not applied")
// Key builds a counter key: Key("A", "x@example.com", "2026-10-13", "i").
func Key(level, id, date, flow string) string {
return Prefix + "/" + level + "/" + id + "/d/" + date + "/" + flow
}
// MarkerKey builds the key recording that a counter's notification went out.
// It uses its own prefix because the web app scans cnt/*<address>*.
func MarkerKey(level, id, date, flow, kind string) string {
return "ntf/" + level + "/" + id + "/d/" + date + "/" + flow + "/" + kind
}
// DayKey returns the UTC calendar day of t, as used in the keys. The web app
// builds its keys with Date.utc_today(), so this must never use local time.
func DayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
@@ -69,6 +86,8 @@ func New(cfg config.RedisConfig) (*Store, error) {
DialTimeout: timeout,
ReadTimeout: timeout,
WriteTimeout: timeout,
ContextTimeoutEnabled: true,
}
write := redis.NewFailoverClient(&base)
// ReplicaOnly picks a random replica and falls back to the master when
@@ -91,6 +110,8 @@ func New(cfg config.RedisConfig) (*Store, error) {
DialTimeout: timeout,
ReadTimeout: timeout,
WriteTimeout: timeout,
ContextTimeoutEnabled: true,
}
}
addrs := cfg.ReadAddrs
@@ -137,7 +158,7 @@ func (s *Store) Incr(ctx context.Context, keys []string) ([]IncrResult, error) {
return nil, nil
}
cmds := make([]*redis.IntCmd, len(keys))
_, err := s.write.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
all, err := s.write.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
for i, k := range keys {
cmds[i] = pipe.Incr(ctx, k)
pipe.Expire(ctx, k, TTL)
@@ -154,6 +175,9 @@ func (s *Store) Incr(ctx context.Context, keys []string) ([]IncrResult, error) {
if !errors.As(err, &rerr) {
return nil, fmt.Errorf("redis incr: %w", err)
}
if allFailed(all) {
return nil, fmt.Errorf("redis incr: %w: %w", ErrNotApplied, err)
}
}
for i, c := range cmds {
if cerr := c.Err(); cerr != nil {
@@ -165,12 +189,29 @@ func (s *Store) Incr(ctx context.Context, keys []string) ([]IncrResult, error) {
return out, nil
}
// allFailed tells a rejected transaction from per-key failures: an INCR on a
// non-number fails alone and its EXPIRE still succeeds, so only a transaction
// Redis refused as a whole leaves every command with an error.
func allFailed(cmds []redis.Cmder) bool {
for _, c := range cmds {
if c.Err() == nil {
return false
}
}
return len(cmds) > 0
}
// OutcomeUnknown reports whether a failed Incr may still have been applied
// by Redis: the request may have been written but the reply lost (timeout,
// EOF, reset). A refused connection, a dial failure or a cancelled context
// never reached Redis and is a definite failure.
func OutcomeUnknown(err error) bool {
if err == nil || errors.Is(err, context.Canceled) {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, ErrNotApplied) {
return false
}
// No connection could be had from the pool, so no command was sent. go-redis
// keeps this error in an internal package; only its text is available.
if strings.Contains(err.Error(), "connection pool timeout") {
return false
}
var opErr *net.OpError
@@ -188,6 +229,18 @@ func OutcomeUnknown(err error) bool {
return strings.Contains(msg, "timeout") || strings.Contains(msg, "reset") || strings.Contains(msg, "broken pipe") || strings.Contains(msg, "EOF")
}
// MarkNotified records that a counter's notification was sent. It overwrites
// any older marker: a counter the web app reset may legitimately cross again.
func (s *Store) MarkNotified(ctx context.Context, key string) error {
return s.write.Set(ctx, key, "1", MarkerTTL).Err()
}
// ClaimNotified atomically takes the right to send a notification. It returns
// true for exactly one caller while the marker exists.
func (s *Store) ClaimNotified(ctx context.Context, key string) (bool, error) {
return s.write.SetNX(ctx, key, "1", MarkerTTL).Result()
}
// GetMaster reads the keys from the write side (the master) with one MGET;
// used to establish the outcome of an Incr whose reply was lost, when a
// replica might not have the increment yet. A missing key reads as 0.
+52 -11
View File
@@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"io"
"net/url"
"sort"
"strings"
"time"
@@ -102,8 +103,12 @@ func (l *Logger) Outcome(out *counting.Outcome, dispatched []bool) {
b.tracked(r, out.Unknown != nil)
}
for i, r := range out.Results {
sent := i < len(dispatched) && dispatched[i]
if r.Reached {
b.reached(r, i < len(dispatched) && dispatched[i])
b.reached(r, sent)
}
if r.Warned {
b.warned(r, sent)
}
}
if out.Skipped != "" {
@@ -128,6 +133,13 @@ func (l *Logger) Dropped(route, traceID string) {
b.flush()
}
// Rejected logs a request refused by the validation layer.
func (l *Logger) Rejected(route, traceID string, status int, reason string) {
b := l.begin(traceID, route)
b.line("rejected", fmt.Sprintf("rejected: %d %s (not counted)", status, reason), map[string]any{"status": status, "reason": reason})
b.flush()
}
// Error logs a request that failed before a plan existed (domain lookup error).
func (l *Logger) Error(route, traceID string, err error) {
b := l.begin(traceID, route)
@@ -136,28 +148,39 @@ func (l *Logger) Error(route, traceID string, err error) {
}
// NotificationSent logs a delivered web app notification (notify hook).
func (l *Logger) NotificationSent(ev notify.Event, url string, attempt int) {
func (l *Logger) NotificationSent(ev notify.Event, rawURL string, attempt int) {
u := redact(rawURL)
b := l.begin(ev.TraceID, ev.Route)
b.line("notification_sent", fmt.Sprintf("sent limit exceeded notification to url %q", url),
map[string]any{"url": url, "attempt": attempt, "level": ev.Level, "id": ev.ID, "flow": ev.Flow})
b.line("notification_sent", fmt.Sprintf("sent %s notification to url %q", kindWords(ev), u),
map[string]any{"url": u, "attempt": attempt, "kind": ev.KindName(), "level": ev.Level, "id": ev.ID, "flow": ev.Flow})
b.flush()
}
// NotificationFailed logs a notification given up on (notify hook).
func (l *Logger) NotificationFailed(ev notify.Event, url string, attempts int, err error) {
func (l *Logger) NotificationFailed(ev notify.Event, rawURL string, attempts int, err error) {
u := redact(rawURL)
b := l.begin(ev.TraceID, ev.Route)
b.line("notification_failed", fmt.Sprintf("failed limit exceeded notification to url %q after %d attempts: %v", url, attempts, err),
map[string]any{"url": url, "attempts": attempts, "error": err.Error(), "level": ev.Level, "id": ev.ID, "flow": ev.Flow})
b.line("notification_failed", fmt.Sprintf("failed %s notification to url %q after %d attempts: %v", kindWords(ev), u, attempts, err),
map[string]any{"url": u, "attempts": attempts, "error": err.Error(), "kind": ev.KindName(), "level": ev.Level, "id": ev.ID, "flow": ev.Flow})
b.flush()
}
// redact hides a password embedded in a notification URL.
func redact(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "<unparseable url>"
}
return u.Redacted()
}
func (b *block) request(p *counting.Plan) {
if p.Route == counting.RouteOut {
msg := fmt.Sprintf("request auth_user=%s recipient=%s mail_from=%s", p.AuthUser, p.Recipient, p.MailFrom)
fields := map[string]any{"auth_user": p.AuthUser, "recipient": p.Recipient, "mail_from": p.MailFrom}
if p.LoginSuffix != "" {
msg += " login_suffix=" + p.LoginSuffix
fields["login_suffix"] = p.LoginSuffix
if p.TokenLogin {
msg += " token_login=true"
fields["token_login"] = true
}
b.line("request", msg, fields)
} else {
@@ -235,7 +258,7 @@ func (b *block) tracked(r counting.Result, reread bool) {
}
func (b *block) reached(r counting.Result, dispatched bool) {
name := notify.URLName(r.Level)
name := notify.URLName(notify.KindLimit, r.Level)
verdict := "notifying " + name
if !dispatched {
verdict = "no " + name + " configured, not notifying"
@@ -245,6 +268,24 @@ func (b *block) reached(r counting.Result, dispatched bool) {
map[string]any{"level": r.Level, "id": r.ID, "flow": r.Flow, "by": r.By, "count": r.Count, "limit": r.Limit, "notifying": dispatched, "url_name": name})
}
func (b *block) warned(r counting.Result, dispatched bool) {
name := notify.URLName(notify.KindWarning, r.Level)
verdict := "notifying " + name
if !dispatched {
verdict = "no " + name + " configured, not notifying"
}
b.line("warning_reached",
fmt.Sprintf("warning reached (level=[%s] id=[%s] flow=[%s] by=[%s]) => %s", r.Level, r.ID, r.Flow, r.By, verdict),
map[string]any{"level": r.Level, "id": r.ID, "flow": r.Flow, "by": r.By, "count": r.Count, "warn_at": r.WarnAt, "limit": r.Limit, "notifying": dispatched, "url_name": name})
}
func kindWords(ev notify.Event) string {
if ev.KindName() == notify.KindWarning {
return "limit warning"
}
return "limit exceeded"
}
// summarize picks the limit keys worth printing for one address: the base
// keys and the address's own overrides. Some domains carry thousands of
// per-mailbox overrides (boxmailhere.com: 9 798); dumping them all would make
+21 -3
View File
@@ -295,7 +295,7 @@ func TestErrorLine(t *testing.T) {
// --- review round 1 lines -----------------------------------------------------
func TestRequestLine_LoginSuffixAndUnusable(t *testing.T) {
plan, err := planner().Plan(counting.Job{Route: counting.RouteOut, TraceID: "m1", AuthUser: "x@wearemiraclesnow.com", LoginSuffix: "SUPPORTTOKEN",
plan, err := planner().Plan(counting.Job{Route: counting.RouteOut, TraceID: "m1", AuthUser: "x@wearemiraclesnow.com", TokenLogin: true,
MailFrom: "x@wearemiraclesnow.com", MailFromUsable: true,
RecipientUnusable: counting.Unusable{Role: "recipient", Value: "u@[192.0.2.1]", Reason: "invalid e-mail address"}})
if err != nil {
@@ -304,7 +304,7 @@ func TestRequestLine_LoginSuffixAndUnusable(t *testing.T) {
l, buf := newLogger("text")
l.Outcome(outcome(plan, 1, 1, 1), nil)
got := buf.String()
if !strings.Contains(got, ts+" <m1> out; request auth_user=x@wearemiraclesnow.com recipient= mail_from=x@wearemiraclesnow.com login_suffix=SUPPORTTOKEN\n") {
if !strings.Contains(got, ts+" <m1> out; request auth_user=x@wearemiraclesnow.com recipient= mail_from=x@wearemiraclesnow.com token_login=true\n") {
t.Errorf("request line should show the login suffix:\n%s", got)
}
if !strings.Contains(got, ts+" <m1> out; recipient address not usable: u@[192.0.2.1] (invalid e-mail address, not counted)\n") {
@@ -318,7 +318,7 @@ func TestRequestLine_LoginSuffixAndUnusable(t *testing.T) {
SenderUnusable: counting.Unusable{Role: "sender", Reason: "missing"}})
l, buf = newLogger("text")
l.Outcome(outcome(plan, 1, 1, 1), nil)
if got := buf.String(); !strings.Contains(got, " <m2> out; sender address not usable: <empty> (missing, not counted)\n") || strings.Contains(got, "login_suffix") {
if got := buf.String(); !strings.Contains(got, " <m2> out; sender address not usable: <empty> (missing, not counted)\n") || strings.Contains(got, "token_login") {
t.Errorf("missing sender line / unexpected suffix:\n%s", got)
}
@@ -411,3 +411,21 @@ func (m malformedTable) Lookup(d string) (*domains.Domain, error) {
}
return m.table.Lookup(d)
}
func TestNotificationLines_RedactCredentials(t *testing.T) {
l, buf := newLogger("text")
ev := notify.Event{TraceID: "t", Route: "out", Level: "O"}
l.NotificationSent(ev, "https://api:s3cret@admin.example/api/limits?level=O", 1)
l.NotificationFailed(ev, "https://api:s3cret@admin.example/api/limits?level=O", 2, errors.New("boom"))
if got := buf.String(); strings.Contains(got, "s3cret") || !strings.Contains(got, "api:xxxxx@admin.example/api/limits?level=O") {
t.Errorf("log:\n%s", got)
}
}
func TestRejected(t *testing.T) {
l, buf := newLogger("text")
l.Rejected("in", "tr9", 400, "recipient: missing")
if got, want := buf.String(), ts+" <tr9> in; rejected: 400 recipient: missing (not counted)\n"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+14
View File
@@ -67,5 +67,19 @@ Requirements and notes:
| 8 | `internal/server` `TestMalformedRowCountsOtherSide`; `internal/counting` `TestPlan_MalformedRowIsNotHosted`; `internal/domains` `TestLookup_MalformedRow`; `internal/txlog` `TestTrail_MalformedRow` |
| 6 | `internal/config` `TestValidate_Errors` (auth_token / unknown keys), `TestLoad_TokenFromEnvOnly`, `TestLoad_Defaults` (`:5555`); `cmd/migadu_counters` `TestRun_FlagsAndExitCodes` |
## Fixed in round 2 (2026-11-09) — regression tests now in the suite
| Merged # | Regression test |
|---|---|
| 9 | `internal/notify` `TestNotify_TimeoutAfterWriteIsNotRetried`, `TestNotify_BackoffReleasesSlot`, `TestNotify_ShutdownFinishesPendingRetries`, `TestNotify_ShutdownDeadlineReportsRealAttempts` |
| 10 | `cmd/migadu_counters` `TestRun_SIGHUPDuringStartupIsQueued` |
| 11 | `internal/server` `TestReject_LeavesTraceInBothLogs` |
| 12 | `internal/domains` `TestParseLimits_OverrideKeysLowercased` |
| 13 | `internal/txlog` `TestNotificationLines_RedactCredentials`; `internal/config` `TestWarnings_PlainHTTPWithBasicAuth` |
| 14 | `internal/server` `TestErrors_DoNotExposeInternals`; `internal/config` `TestLoad_Defaults` |
| 15 | `internal/config` `TestValidate_Errors` (case "sentinel needs password"), `TestLoad_SentinelPasswordOptional` |
| 16 | `cmd/counterctl` `TestDateWarning_Boundary` |
| 17 | `internal/server` `TestPanic_CountedInRequestMetrics` |
The proof files stay here as the review record; they no longer compile against the current API
(`CountResponse`, `store.Incr`) and are not meant to.
+18 -49
View File
@@ -1,6 +1,6 @@
# TODO — migadu_counters
State as of 2026-10-15. Review findings refer to `review/README.md`; the proof tests live in
State as of 2026-11-09. Review findings refer to `review/README.md`; the proof tests live in
`review/_proof_tests/` and should become regression tests as each item is fixed (round 1 and items
7–8 are mapped at the end of `review/_proof_tests/README.md`).
@@ -12,58 +12,24 @@ State as of 2026-10-15. Review findings refer to `review/README.md`; the proof t
- #7 plus-addressing counts against the mailbox (`domains.Mailbox`).
- #8 malformed CDB row is "not hosted" + warning (`domains.ErrMalformed`).
## Review round 2 — remaining findings
## Review round 2 — done 2026-11-09
Suggested order: #10, #15, #9, then the rest in one sweep.
#9-#17 fixed, plus the new findings from the 2026-11-09 review: repeat notifications after a lost Redis
reply, pool timeouts treated as unknown outcomes, login suffix in the counting log, no retry of rejected
transactions, `/counters` punycode, counter day taken from arrival time, CDB corruption checks, context
deadlines on Redis sockets, example config path and placeholders, non-zero exit on a failed drain.
Also added: the 80% early warning (`warn_percent`, `warn_*_url`) and per-counter notification markers.
### Can stop the daemon or lose counts
## Open
- [ ] **#10 SIGHUP during startup kills the daemon.** The signal handler is installed after the Redis
ping (up to 2 s). Install `signal.Notify` before anything slow in `cmd/migadu_counters/main.go`
(`run`), and queue a reload that arrives before the app is ready. Evidence: `review/_proof_tests/a/run/`.
- [ ] **#15 Sentinel mode starts without a Redis password.** Every write fails; only `/health` and
metrics show it. Make an empty `redis.password` fatal in `config.Validate` when `sentinels` is set
(allow an explicit opt-out such as `password_optional = true` for dev). Proof:
`b/internal/config/review_b_test.go`, case "sentinel without password".
- [ ] **Persistent / recoverable counting on Redis outage** (deferred from round 1, #4). A Redis stall
longer than a few seconds fills the queue and new requests are dropped (503, `dropped_total`).
Options: marker key per transaction for exact lost-reply recovery; a disk-backed queue. Decide
whether the drop-and-alert behaviour is acceptable before building anything.
### Correctness
- [ ] **#9 Notification retries can send duplicate customer emails.** The web app's mailbox-level
handler (`core/email.ex:3572-3584`) emails on every call; the org handler is guarded once per day.
In `internal/notify`: treat a slow-but-2xx attempt as success (do not retry after the client timeout
if the request was sent — or make the retry budget shorter than the web app's handling time), do
not keep the concurrency slot while backing off, let Shutdown finish pending retries up to its
deadline, and fix the "after N attempts" count in the failure line.
- [ ] **#12 Override keys are not lowercased.** `Info@/o=5` would be ignored and the domain default
used instead. Lowercase the key in `domains.ParseLimits`; add a test row. No such keys in today's
data.
- [ ] **#16 counterctl warns one day too early.** A date exactly 31 days back is reported as expired.
Compare against the key TTL in whole days (`now.UTC` day minus date > 31). Proof:
`b/cmd/counterctl/review_b_test.go` `TestReviewB_31DayWarningOffByOne`.
- [ ] **#18 Work keeps running after a handler timeout.** Not reproduced by the reviewer and largely
covered by the queue change (the synchronous part only validates). Re-check that
`http.TimeoutHandler` cannot leave a goroutine writing after the 503, then close.
### Operability / security hygiene
- [ ] **#11 Rejected requests leave no trace.** 4xx rejections write nothing to the counting log and no
`trace_id` to the app log. Decode `trace_id` best-effort before rejecting; add a
`rejected: <status> <reason>` line to the counting log and `trace_id` to the warn line. Proof:
`b/internal/server/review_b_test.go` `TestReviewB_RejectLogHasNoTraceID`.
- [ ] **#13 Credentials in the notify URL are written to the log.** Redact `user:pass@` from URLs in
the txlog `sent/failed notification` lines; warn at startup when a notify URL is plain `http://`
with basic auth. Proof: `b/internal/server/review_b3_test.go`.
- [ ] **#14 Error responses expose internals.** 503 bodies carry Redis addresses and file paths; keep
the detail in the log and answer a generic message. Default `admin_addr` to `127.0.0.1:9095`
(currently `:9095`, all interfaces, no auth). Proof: `TestReviewB_ErrorLeak`.
- [ ] **#17 A recovered panic is not counted in `requests_total`.** The recoverer sits outside
`observe`; swap the order or record the 500 explicitly. Proof: `TestReviewB_PanicNotInMetrics`.
- [ ] **Test gaps (B F13).** Watcher tests can occasionally pass on a stale signal — make them wait for
the specific reload they triggered. Add a test for `handler_timeout` vs queue timeout interplay.
Decide whether drop-and-alert is acceptable before building anything.
- [ ] **Exempt `mail_from` on `/in`.** An outside sender using `MAIL FROM:<support@migadu.com>` avoids the
recipient's incoming count, unless mizu rejects such senders inbound (not confirmed). Matches the
old Lua rule; decide whether to keep it.
- [ ] **Test gap.** Watcher tests can occasionally pass on a stale signal: make them wait for the
specific reload they triggered.
## Outside this repo (deployment prerequisites)
@@ -77,9 +43,12 @@ Suggested order: #10, #15, #9, then the rest in one sweep.
`contrib/migadu_counters-pull.cron` (see `contrib/README.md`), agent host key acceptance,
firewall: mailqueuer hosts → :5555, service → Sentinel :26379/:6379 and admin.migadu.com:443.
- [ ] **migadu_web** `/api/limits` `D` branch (domain level); until then `notify.domain_url` stays empty.
- [ ] **migadu_web3 branch `limit-warning-notifications`**: review the warning email wording, merge and deploy,
then set `warn_org_url` and `warn_address_url`. Until then warnings stay off.
- [ ] **Firewall / monitoring**: `admin_addr` now defaults to localhost; set it explicitly if `/metrics` or
`/health` are scraped from another host.
- [ ] **Limits re-tuning**: outgoing is counted per recipient (Lua counted per message), so existing
`*/o` limits are stricter. Review before relying on `outgoing_limit_exceeded!`.
- [ ] **Retire stats-merger's quota module** once this service is the source (only fernrohr reads it).
- [ ] **Dependencies**: `golang.org/x/net` is pinned to v0.43.0 because the offline module cache had
it; run `go get -u golang.org/x/net && go mod tidy` with network access.
- [ ] **git init** the repo (never asked for so far).