First commit Go counter service
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
# Plan: `migadu_counters` — real-time mail counting service (Go)
|
||||
|
||||
## Context
|
||||
|
||||
Daily send/receive limits used to be counted and enforced by the rspamd Lua plugin
|
||||
`ansible-freebsd2/roles/rspamd-postfix/templates/rspamd/plugins.d/migadu_counters.lua`
|
||||
(Redis counters per address + org, soft-reject/reject on breach, web app notified once).
|
||||
Mail now flows mizu → mailqueuer and bypasses rspamd, so nothing counts in real time.
|
||||
`stats-merger/internal/quota` recomputes usage from the delivery ledger after each merge, but it is
|
||||
reporting-only today: only fernrohr reads `/api/quota/{status,stats,overquota}`; nothing calls `/check`.
|
||||
|
||||
`migadu_counters` is a new standalone Go service that becomes the single counting source and the
|
||||
enforcement trigger:
|
||||
- mailqueuer calls it **fire-and-forget**, once per ingested recipient, from its publish loop;
|
||||
- one route per flow: `POST /in`, `POST /out` (`/rel` later, not planned here);
|
||||
- it increments Redis counters at **Address / Domain / Organization** level, in the key format the
|
||||
web app already reads. The domain level is turned back on (Lua defined it but never tracked it);
|
||||
- when an increment crosses a limit it **notifies the web app** (`migadu_web` `GET /api/limits`), which
|
||||
decides what to do (notify the user, remove sending/receiving);
|
||||
- a small CLI, `counterctl`, shows an address's incoming/outgoing counters for today or a given date
|
||||
(see "CLI: counterctl" below).
|
||||
|
||||
The service returns no verdicts and enforces nothing itself.
|
||||
Out of scope: `/rel`, the mailqueuer client change, retiring stats-merger quota, the web app's `D` branch,
|
||||
deployment (see prerequisites at the end).
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
sender ──SMTP──▶ mizu ──POST /ingest──▶ mailqueuer
|
||||
X-Trace-Id, X-Mail-From, │ one queue message per recipient;
|
||||
X-Mail-To, X-Auth-User, │ after each successful publish:
|
||||
X-Junk │ fire-and-forget HTTP call, reply ignored
|
||||
▼
|
||||
POST /out {"trace_id","auth_user","mail_from","recipient"} ← X-Auth-User set
|
||||
POST /in {"trace_id","mail_from","recipient","junk"} ← X-Auth-User empty
|
||||
Authorization: Bearer <token> (mailqueuer sends it, migadu_counters checks it; 401 if wrong)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ migadu_counters (daemon :8095) │
|
||||
│ ┌─────────────────────────────────┐ │ rejected here, nothing counted:
|
||||
│ │ HTTP validation layer │ │ 405 method · 413 body > 64 KB
|
||||
│ │ method → size → bearer token → │ │ 401 bad/missing token · 403 source IP
|
||||
│ │ source IP → content-type → JSON │ │ 415 content-type · 400 json / fields /
|
||||
│ │ → fields → e-mail syntax │ │ e-mail syntax · 503 handler timeout
|
||||
│ └───────────────┬─────────────────┘ │
|
||||
│ ▼ validated request │
|
||||
rspamd_domains_db ───▶│ counting: 1. domain in CDB? → org_id, limits │
|
||||
(CDB: domain → │ 2. MULTI: INCR A/D/O keys, EXPIRE 31 d │
|
||||
org_id | limits) │ 3. did this INCR cross a limit? → notify │
|
||||
└───┬─────────────────────────┬───────┘ admin :9095 /health /metrics
|
||||
│ │
|
||||
Redis Sentinel ◀──┘ └──────▶ GET https://admin.migadu.com/api/limits
|
||||
writes → master ?level=&id=&flow=&period=d&by=&count=
|
||||
reads → replicas (basic auth) → migadu_web notifies the user
|
||||
DB 0: cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o> or disables sending/receiving
|
||||
|
||||
counterctl --address x@y [--date YYYY-MM-DD] ──GET /counters (Bearer)──▶ migadu_counters ──▶ Redis replicas
|
||||
```
|
||||
|
||||
## Existing pieces reused or matched
|
||||
|
||||
- **Limits**: the CDB the Lua plugin reads, `/usr/local/etc/rspamd/db/rspamd_domains_db`, built by
|
||||
`ansible-freebsd2/roles/agent/templates/extract.sh.j2:478` with tinycdb `cdb -m` (no NUL terminators,
|
||||
exact-byte lookups like rcptd). Key = `domain`, value = `<org_id>|O/i=..,O/o=..,D/i=..,A/o=..,alice@/o=..`.
|
||||
Parsing and limit resolution follow `stats-merger/internal/quota/quota.go` (`ParseLimits` L66,
|
||||
`DomainLimits.limit` L116): keys lowercased, malformed pairs skipped, `<local>@/<flow>` overrides
|
||||
`A/<flow>`, missing key = unlimited. A domain present in the CDB is "Migadu-hosted".
|
||||
- **Redis keys** — must match the web app's key builder (`migadu_web/apps/core/lib/core/redis_sentinel.ex:73-103`):
|
||||
`cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o>` with id = address / domain / org_id, **date in UTC**
|
||||
(web app uses `Date.utc_today()`), **Redis DB 0** (web app reads `:redis_sentinel_db0`; rspamd uses
|
||||
`redis_rspamd_db: 0`). TTL 31 days. DB 1 and the `/s` storage keys belong to the web app; never touched.
|
||||
The web app may DEL today's keys to reset a customer (`redis_sentinel.ex:145-153`); counting must keep
|
||||
working after that, and a second crossing must notify again.
|
||||
- **Web app notification**: `GET https://admin.migadu.com/api/limits?level=&id=&flow=&period=&by=&count=`
|
||||
with basic auth (`apps/admin_web/lib/admin_web/controllers/internal_api/notification_controller.ex:33`,
|
||||
`router.ex:99` `Plug.BasicAuth`). All six params are pattern-matched, so all must be present.
|
||||
`id` = address for A, domain name for D, org id for O. Credentials: `vault_migadu_web.internal_api_basic_auth_*`
|
||||
(see `rspamd-postfix/templates/rspamd/local.d/migadu_counters.conf.j2`).
|
||||
- **Redis topology**: `ansible-freebsd2/roles/redis-sentinel`, `inventory/hosts.ini:160`: hosts `redisSentinel0-2`,
|
||||
Redis :6379 + Sentinel :26379, master name `mymaster`, 1 master + 2 replicas, one password
|
||||
(`vault_redis_sentinel_password`) for Redis; Sentinel itself has no `requirepass`.
|
||||
- **Service conventions** from `~/Work/rcptd` (`cmd/rcptd/main.go`): TOML config via `-config` + default paths,
|
||||
bearer `auth_token` with env override, slog, CDB behind `atomic.Pointer` with a 30 s grace close,
|
||||
SIGHUP + fsnotify reload (`internal/watcher/watcher.go` table mode), admin port with `/health` + `/metrics`,
|
||||
graceful shutdown. No dependency on rcptd's `shared` module (not present locally).
|
||||
- **Libraries** (in the module cache): `github.com/colinmarc/cdb`, `github.com/BurntSushi/toml` v1.6.0,
|
||||
`github.com/fsnotify/fsnotify` v1.9.0, `github.com/redis/go-redis/v9` v9.7.3, `github.com/prometheus/client_golang`;
|
||||
tests: `github.com/alicebob/miniredis/v2` v2.34.0.
|
||||
|
||||
## API (called by mailqueuer, fire-and-forget, one request per recipient)
|
||||
|
||||
> **Revised 2026-10-14 (review round 1, `review/README.md` #1–#6):** `/in` and `/out` now validate, enqueue
|
||||
> and answer `202 {"accepted":true}`; counting runs in a worker pool (`queue_size`, `workers`) under its own
|
||||
> time budget, and the counted values appear only in the counting log and `GET /counters`. A full queue answers
|
||||
> `503 {"error":"queue full"}`. Only the counted sides are validated (`mail_from` never rejects; `auth_user` and
|
||||
> `recipient` independently), IDN domains are converted to punycode, `user@dom@SUFFIX` logins count under the
|
||||
> base address. `INCR` replaces `INCRBY`; per-key EXEC failures and lost replies are handled individually
|
||||
> (re-read from the master). Parsed CDB records are cached (`max_cached_domains`). `auth_token` is mandatory,
|
||||
> unknown config keys are fatal, the default API port is `:5555`. The `200 {"counted":…}` contract below is
|
||||
> the original design and is superseded by the README.
|
||||
|
||||
mailqueuer's ingest handler is already per recipient: it loops over `toPublish`
|
||||
(`mailqueuer/internal/handler/ingest.go:235-306`), publishes one queue message per recipient (own uuid `id`,
|
||||
L237) and marks dedup only after a successful publish (L263-272). Both routes are called from inside that loop,
|
||||
right after a successful `Publish`, with exactly one recipient. The route is chosen by `X-Auth-User`:
|
||||
non-empty → `/out`, empty → `/in`. mailqueuer never waits for or acts on the response.
|
||||
|
||||
**Authentication**: every request carries `Authorization: Bearer <token>` — mailqueuer sends it (the same
|
||||
header it already sends to rcptd, `internal/routing/routing.go:256-258`), migadu_counters validates it
|
||||
(constant-time compare against `auth_token`; missing/wrong → `401`, nothing counted). One shared token,
|
||||
set in both configs; no other auth. All routes take a JSON body ≤ 64 KB. Processing is **synchronous**: one Redis
|
||||
round trip, then the response. Only web-app notifications run in the background.
|
||||
- `200` `{"counted":[{"level":"A","id":"x@example.com","flow":"o","count":12,"limit":100,"reached":false},…]}`
|
||||
(`reached: true` on the increment that crossed the limit → notification triggered). Skipped requests return
|
||||
`{"counted":[],"skipped":"exempt|unknown_domain|junk"}`.
|
||||
- `400` invalid body/address, `401` bad token, `503` Redis error (nothing counted).
|
||||
|
||||
### `POST /out` — authenticated submission
|
||||
```json
|
||||
{ "trace_id": "…", "auth_user": "x@example.com", "mail_from": "x@example.com", "recipient": "y@example.com" }
|
||||
```
|
||||
1. **Sender outgoing +1** on A(`auth_user`) / D(its domain) / O(its org) — only if `auth_user`'s domain is in the CDB.
|
||||
2. **Recipient incoming +1** on A / D / O of the recipient's domain row — only if that domain is in the CDB
|
||||
(any Migadu-hosted domain, any org). That mail never passes `/in`.
|
||||
|
||||
Example: `x@example.com` → `y@example.com`, `s@gmail.com` = two `/out` calls.
|
||||
Call 1: out A x +1, D example.com +1, O +1; in A y +1, D example.com +1, O +1. Call 2: out +1 on each; gmail not
|
||||
in CDB → no incoming. Totals: outgoing +2 per level, incoming for y +1 per level.
|
||||
|
||||
### `POST /in` — inbound mail from outside
|
||||
```json
|
||||
{ "trace_id": "…", "mail_from": "s@gmail.com", "recipient": "y@example.com", "junk": false }
|
||||
```
|
||||
Recipient incoming +1 on A / D / O if its domain is in the CDB. The sender is never counted.
|
||||
`junk = true` is skipped unless `count_junk = true` (Lua only counted `no action` mail).
|
||||
|
||||
### `GET /counters?address=…` or `?domain=…` `[&date=YYYY-MM-DD]`
|
||||
Read-only view (used by `counterctl`), served from the **read client**. `date` is an ISO 8601 calendar date
|
||||
(`YYYY-MM-DD`, interpreted as a UTC day); default = today UTC; a future date is `400`. Keys live 31 days, so
|
||||
older dates return zeros. Exactly one of `address`/`domain` is required.
|
||||
```json
|
||||
{ "query": "me@example.com", "mode": "address", "date": "2026-10-13",
|
||||
"managed": true, "exempt": false, "domain": "example.com", "org_id": 1234,
|
||||
"levels": [
|
||||
{"level": "A", "id": "me@example.com", "in": 12, "in_limit": 500, "out": 3, "out_limit": 200},
|
||||
{"level": "D", "id": "example.com", "in": 40, "in_limit": 1000, "out": 9, "out_limit": 400},
|
||||
{"level": "O", "id": "1234", "in": 120, "in_limit": 5000, "out": 30, "out_limit": 1000} ] }
|
||||
```
|
||||
`in_limit`/`out_limit` are `null` when unlimited. `mode: "domain"` returns the D and O rows only.
|
||||
`managed: false` (domain not in the CDB) still returns the raw counters with `null` limits — Lua-era keys may exist.
|
||||
Values are read with one `MGET` of the ≤ 6 keys.
|
||||
|
||||
### Shared rules
|
||||
- Addresses are trimmed and lowercased; validation = one `@`, non-empty parts, ≤ 254 chars.
|
||||
- **Exempt list** (default `support@migadu.com`, `contact@migadu.com`): if `auth_user`, `mail_from` or
|
||||
`recipient` is exempt, nothing is counted for that request (Lua: mail to/from support never costs quota).
|
||||
- `trace_id` (mizu's `X-Trace-Id`, shared by all recipients of one message) is logged for correlation only;
|
||||
mailqueuer's internal per-recipient queue id is **not** sent. No Redis idempotency keys: mailqueuer already
|
||||
guarantees at-most-once per (trace, recipient) publish; the residual case (mailqueuer restart + mizu retry)
|
||||
also double-delivers the mail, so the count stays truthful.
|
||||
- `O` and `D` limits come from the domain row that triggered the increment (sender's row for outgoing,
|
||||
recipient's row for incoming) — same caveat as Lua if domains of one org carry different `O/*` values.
|
||||
|
||||
## HTTP validation layer (mailqueuer → migadu_counters)
|
||||
|
||||
Everything mailqueuer sends passes this layer **before** any CDB or Redis access; a request that fails any step is
|
||||
answered with a JSON error `{"error":"…"}` and nothing is counted. Two parts:
|
||||
|
||||
**1. Middleware chain** (`internal/server/middleware.go`, applied in this order to `/in`, `/out`; `/counters`
|
||||
gets the same chain with method GET and no body steps):
|
||||
|
||||
| # | Step | Rule | Failure |
|
||||
|---|------|------|---------|
|
||||
| 1 | recover | panic in a handler → log with stack, keep the daemon alive | `500` |
|
||||
| 2 | method | `POST` only (`GET` for `/counters`) | `405` + `Allow` header |
|
||||
| 3 | body size | `http.MaxBytesReader` 64 KB | `413` |
|
||||
| 4 | bearer token | `Authorization: Bearer <token>`, constant-time compare with `auth_token` | `401` + `WWW-Authenticate: Bearer` |
|
||||
| 5 | source IP | optional `allowed_cidrs` (config); matched on `RemoteAddr` only — `X-Forwarded-For` is never trusted (no proxy in front) | `403` |
|
||||
| 6 | content type | `Content-Type: application/json` (parameters such as `charset` ignored) | `415` |
|
||||
| 7 | JSON decode | exactly one JSON object; syntax errors rejected; **unknown fields ignored** so mailqueuer can add fields without breaking counting | `400` |
|
||||
| 8 | request validation | per-route field rules below | `400` |
|
||||
| 9 | timeout | `http.TimeoutHandler` (`handler_timeout`, default 4 s) around the counting work | `503` |
|
||||
|
||||
Every rejection is logged at `warn` with route, remote address, status, reason and `trace_id` (if parsable) and
|
||||
counted in `requests_total{route,status}` — a burst of `400`/`401` from mailqueuer means contract drift or a
|
||||
token mismatch and should alert.
|
||||
|
||||
**2. Request validators** (`internal/api/validate.go`, pure functions, table-tested):
|
||||
|
||||
| Field | `/out` | `/in` | Rule |
|
||||
|-------|--------|-------|------|
|
||||
| `trace_id` | optional | optional | ≤ 128 chars, printable ASCII; control characters/newlines stripped before it reaches any log line (log-injection guard) |
|
||||
| `auth_user` | **required** | — (ignored) | e-mail syntax |
|
||||
| `recipient` | **required** | **required** | e-mail syntax |
|
||||
| `mail_from` | optional | optional (`""` = bounce sender `<>`) | e-mail syntax when non-empty |
|
||||
| `junk` | — (ignored) | optional, default `false` | boolean |
|
||||
|
||||
E-mail syntax = trimmed, lowercased, ≤ 254 chars, exactly one `@`, non-empty local part and domain, no
|
||||
whitespace/control characters; the domain is ASCII (punycode accepted). Deliberately permissive: the CDB lookup
|
||||
decides whether an address is counted, the validator only guards against garbage and log injection.
|
||||
Errors name the field: `{"error":"recipient: missing"}`, `{"error":"auth_user: invalid e-mail address"}`.
|
||||
|
||||
Config: `allowed_cidrs = ["10.0.0.0/8"]` (optional; empty = any source), `handler_timeout` (see daemon section).
|
||||
The response codes mailqueuer may see are therefore: `200` counted/skipped, `4xx` rejected by this layer,
|
||||
`503` Redis unavailable or timeout — it ignores all of them (fire-and-forget), they exist for operators and tests.
|
||||
|
||||
## Redis (`internal/store`)
|
||||
|
||||
Config has a **write** side and a **read** side:
|
||||
- **Sentinel mode (production)**: `sentinels`, `master_name`, `password` (+ optional `sentinel_password`).
|
||||
Write = `redis.NewFailoverClient` (follows failover). Read = `NewFailoverClient{ReplicaOnly: true}`; go-redis
|
||||
picks a random replica and falls back to the master when none is known (`sentinel.go:526`).
|
||||
- **Static mode (dev/tests, when `sentinels` is empty)**: `write_addr` + `read_addrs` (round-robin; defaults to
|
||||
`write_addr`).
|
||||
- Both pooled (`pool_size`); one `timeout_ms` for dial/read/write. `db` must be `0` (validated, startup error otherwise).
|
||||
|
||||
Counting = one `TxPipeline` (MULTI/EXEC) per request: `INCRBY key 1` + `EXPIRE key 2678400` for every affected key
|
||||
(≤ 6 keys). EXEC returns the new values, so no read-after-write and replica lag is irrelevant. Increments are
|
||||
atomic (fixes the Lua GET-then-INCR race); counting continues past the limit.
|
||||
|
||||
**Limit crossing**: for each incremented key with a configured limit `L`, notify iff `new − amount < L ≤ new`
|
||||
(with amount 1: `new == L`). Exactly one increment can cross, across all workers/instances, with no marker key.
|
||||
After a web-app reset (DEL) the counter crosses again and notifies again. Accepted gap (same as Lua): a limit
|
||||
lowered below the current count mid-day produces no crossing.
|
||||
|
||||
Redis errors → `503`, log, `redis_errors_total{op}`.
|
||||
|
||||
## Notification (`internal/notify`)
|
||||
|
||||
- One URL per level in `[notify]`: `org_url` (O), `address_url` (A), `domain_url` (D). Empty URL = that level is
|
||||
counted and `limit_reached_total` is incremented, but no HTTP call (useful while the web app's `D` branch is a TODO).
|
||||
Basic auth and timeout are shared.
|
||||
- `GET <url>` with template vars `$LEVEL $ID $FLOW $PERIOD(=d) $ADDRESS $COUNT $LIMIT`, each `url.QueryEscape`d.
|
||||
`ADDRESS` = the address that triggered it (sender for outgoing, recipient for incoming). `COUNT` = new value.
|
||||
- Runs in a background goroutine (semaphore of 8 to not flood the web app): up to 5 attempts, exponential
|
||||
backoff 1 s → 16 s. Final failure → error log + `notifications_total{level,result="failed"}` (alertable).
|
||||
Shutdown waits ≤ 10 s for in-flight notifications.
|
||||
|
||||
## CLI: `counterctl`
|
||||
|
||||
```
|
||||
counterctl --address me@dimitrijedimitrijevic.com # today (UTC)
|
||||
counterctl --address me@dimitrijedimitrijevic.com --date 2026-10-12
|
||||
counterctl --domain dimitrijedimitrijevic.com --json
|
||||
```
|
||||
- Flags (Go `flag`, so `-x` and `--x` both work): `--address` | `--domain` (exactly one), `--date YYYY-MM-DD`
|
||||
(ISO 8601 calendar date; default today UTC; parsed with `time.Parse("2006-01-02")`; future date = error;
|
||||
older than 31 days = warning on stderr that keys have expired), `--json` (raw API response), `--config PATH`,
|
||||
`--url` (override), `--version`.
|
||||
- Talks to the running service's `GET /counters` instead of Redis directly — one code path for key building
|
||||
and limit resolution, no Redis/CDB access needed from the operator's shell, same approach as `rcptctl`.
|
||||
Config discovery mirrors the service and `rcptctl` (`cmd/rcptctl/main.go:65-75`): `--config`, else
|
||||
`/etc/migadu_counters/config.toml`, `/usr/local/etc/migadu_counters/config.toml`; reads `addr` and
|
||||
`auth_token` only (`MIGADU_COUNTERS_AUTH_TOKEN` overrides); `addr` like `:8095` becomes
|
||||
`http://localhost:8095` (`rcptctl/api.go:52-60`); warns when the host is not local (token over plaintext).
|
||||
- Output (`text/tabwriter`), limits shown as `-` when unlimited, `!` appended when value ≥ limit:
|
||||
```
|
||||
me@dimitrijedimitrijevic.com date 2026-10-13 (UTC) domain dimitrijedimitrijevic.com org 1234
|
||||
LEVEL ID IN LIMIT OUT LIMIT
|
||||
A me@dimitrijedimitrijevic.com 12 500 3 200
|
||||
D dimitrijedimitrijevic.com 40 1000 9 400
|
||||
O 1234 120 5000 30 1000
|
||||
```
|
||||
`managed: false` prints a `domain is not in rspamd_domains_db (not counted)` note after the table;
|
||||
`exempt: true` prints `address is exempt from counting`.
|
||||
- Exit codes: `0` success, `1` usage error / bad date / service unreachable / non-200 (message on stderr).
|
||||
|
||||
## Counting transaction log (`internal/txlog`)
|
||||
|
||||
A second logger, separate from the slog application log, that writes the Lua plugin's step-by-step lines for
|
||||
**every** counting transaction (the Lua `infox` lines from `migadu_counters.lua:217/235/266/268/271/283`),
|
||||
so existing grep habits keep working. rspamd's task id `<ad8525>` is replaced by `<trace_id>` (shared with
|
||||
mizu/mailqueuer logs via `X-Trace-Id`), and the route is added. `breach=` becomes `reached=` because the
|
||||
meaning changed (Lua: already over → rejected; here: this increment crossed the limit).
|
||||
|
||||
Text format (default), timestamps UTC with milliseconds, one block per request:
|
||||
```
|
||||
2026-10-06 01:12:06.412 <ad8525> out; request auth_user=noah@wearemiraclesnow.com recipient=y@example.org mail_from=noah@wearemiraclesnow.com
|
||||
2026-10-06 01:12:06.412 <ad8525> out; local domain: wearemiraclesnow.com account_id=116681
|
||||
2026-10-06 01:12:06.412 <ad8525> out; domain: wearemiraclesnow.com limits_map={A/i=500, A/o=100, D/i=10000, D/o=2200, O/i=10000, O/o=2200}
|
||||
2026-10-06 01:12:06.412 <ad8525> out; determined limit (level=[A] flow=[o] key=[cnt/A/noah@wearemiraclesnow.com/d/2026-10-06/o]) => 100
|
||||
2026-10-06 01:12:06.412 <ad8525> out; determined limit (level=[D] flow=[o] key=[cnt/D/wearemiraclesnow.com/d/2026-10-06/o]) => 2200
|
||||
2026-10-06 01:12:06.412 <ad8525> out; determined limit (level=[O] flow=[o] key=[cnt/O/116681/d/2026-10-06/o]) => 2200
|
||||
2026-10-06 01:12:06.412 <ad8525> out; recipient domain not in domains db: example.org (not counted)
|
||||
2026-10-06 01:12:06.414 <ad8525> out; tracked address counters for noah@wearemiraclesnow.com => count=12 limit=100 reached=false
|
||||
2026-10-06 01:12:06.414 <ad8525> out; tracked domain counters for wearemiraclesnow.com => count=1830 limit=2200 reached=false
|
||||
2026-10-06 01:12:06.414 <ad8525> out; tracked account counters for 116681 => count=2200 limit=2200 reached=true
|
||||
2026-10-06 01:12:06.414 <ad8525> out; limit reached (level=[O] id=[116681] flow=[o] by=[noah@wearemiraclesnow.com]) => notifying org_url
|
||||
2026-10-06 01:12:06.903 <ad8525> out; sent limit exceeded notification to url "https://admin.migadu.com/api/limits?level=O&id=116681&flow=o&period=d&by=noah%40wearemiraclesnow.com&count=2200"
|
||||
```
|
||||
- A local recipient produces the same `local domain / limits_map / determined limit / tracked …` block for
|
||||
`flow=[i]` under the recipient's domain row. `/in` requests log the recipient block only.
|
||||
- `determined limit … => unlimited` when no limit is configured. Skips are logged as one line:
|
||||
`skipped: exempt address <addr>`, `skipped: junk`, `recipient domain not in domains db: <d> (not counted)`,
|
||||
`sender domain not in domains db: <d> (not counted)`.
|
||||
- Notification outcome lines come from the background sender, tagged with the same `<trace_id>`:
|
||||
`sent limit exceeded notification to url "…"` or
|
||||
`failed limit exceeded notification to url "…" after 5 attempts: <err>`.
|
||||
- Redis failure: `redis error: <err> (nothing counted)`.
|
||||
- `format = "json"` switches the same lines to one JSON object per line (`ts, trace_id, route, event,
|
||||
level, flow, key, id, count, limit, reached, domain, org_id, limits, url, error`) for later ingestion.
|
||||
- The "tracked" lines are written after the pipeline EXEC (counts are the returned values); the earlier lines are
|
||||
written before the Redis call, so a Redis failure still leaves the lookup trail in the log.
|
||||
- Writer: the same `LogWriter` type as the app log (mutex, unbuffered for `tail -f`, `Reopen` on SIGHUP).
|
||||
Config: `[logging] counting_output` (default: same destination as `output`; `"off"` disables),
|
||||
`counting_format` (`text` | `json`). The per-request details live only here; the app log keeps startup,
|
||||
reloads, warnings and errors, to avoid duplication.
|
||||
|
||||
## Process model: standalone daemon
|
||||
|
||||
`cmd/migadu_counters` is a long-running **foreground** process:
|
||||
- Two `http.Server`s: the API (`/in`, `/out`, `/counters`, bearer auth, `http.TimeoutHandler`) on `addr`, and the
|
||||
admin server (`/health`, `/metrics`, no auth) on `admin_addr`. Server timeouts as in rcptd:
|
||||
`read_header_timeout=5`, `write_timeout=30`, `idle_timeout=60`, `handler_timeout=4` (seconds).
|
||||
- Startup: load config → logger → open CDB (fatal if unreadable) → create Redis clients + PING the write side
|
||||
(warn, not fatal: the service must come up while Redis is briefly away) → start fsnotify watcher → listen.
|
||||
- Signals: `SIGHUP` → reopen both log files (app + counting) + reload CDB (`atomic.Pointer` swap, old file
|
||||
closed after 30 s);
|
||||
`SIGTERM`/`SIGINT` → stop accepting, drain in-flight requests (≤ 10 s), wait for background notifications
|
||||
(≤ 10 s), close Redis clients, exit 0.
|
||||
- Supervision on FreeBSD mirrors rcptd (`ansible-freebsd3/roles/rcptd/templates/rcptd.rc.j2`): an rc.d script runs
|
||||
the binary under `/usr/sbin/daemon -f -H -p <pidfile> -o <logfile>`; daemon(8) owns the pidfile, restarts on
|
||||
exit and captures stderr; `service migadu_counters reload` = `kill -HUP $(cat pidfile)` (the child's pid).
|
||||
Therefore **no `daemon`/`pid_file` options** (rcptd's config template warns that writing the same pidfile from
|
||||
the process breaks the supervisor). Logging defaults to stderr for daemon(8) `-o`; a file path is supported
|
||||
with `Reopen` on SIGHUP for newsyslog.
|
||||
- `[logging]` is rcptd's `LoggingConfig` verbatim (`output` = stderr|stdout|path, `format` = console|json,
|
||||
`level`); copy `rcptd/internal/config/logger.go` (self-contained).
|
||||
- Binaries carry `version/commit/date` via ldflags (`-version` flag); Makefile mirrors rcptd's: `build`, `test`,
|
||||
`freebsd` (CGO_ENABLED=0, `migadu_counters-freebsd-amd64`, `counterctl-freebsd-amd64`), `build-linux`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
migadu_counters/
|
||||
├── go.mod # module migadu_counters (exists)
|
||||
├── Makefile # build / test / freebsd / build-linux, ldflags version info
|
||||
├── cmd/migadu_counters/main.go # config, logger, CDB load/reload, redis clients, HTTP + admin servers, signals
|
||||
├── cmd/counterctl/main.go # CLI: flags, config discovery, GET /counters, table/json output
|
||||
├── internal/api/types.go # JSON types shared by the server (/in, /out, /counters) and counterctl
|
||||
├── internal/api/validate.go # per-route request validators, e-mail syntax, trace_id sanitizing
|
||||
├── internal/config/config.go # TOML structs, defaults, validation (sentinel vs static, db==0, urls)
|
||||
├── internal/config/logger.go # LoggingConfig + LogWriter.Reopen (from rcptd), counting_* fields
|
||||
├── internal/txlog/txlog.go # counting transaction logger: text (Lua-like) / json, per-request block
|
||||
├── internal/domains/domains.go # CDB open/lookup, ParseRecord/ParseLimits, Limit(level, flow, local)
|
||||
├── internal/watcher/watcher.go # fsnotify reload of the CDB (rcptd table mode, trimmed)
|
||||
├── internal/store/redis.go # write/read clients, Incr pipeline, Get for /counters
|
||||
├── internal/counting/counting.go # /in, /out request → increments (level, id, flow, limit), exempt/junk/unknown
|
||||
├── internal/notify/notify.go # per-level URL, templating/escaping, background sender with retries
|
||||
├── internal/server/middleware.go # recover, method, body size, bearer token, allowed_cidrs, content-type, decode, timeout
|
||||
├── internal/server/server.go # /in, /out, /counters handlers; admin /health, /metrics
|
||||
├── internal/metrics/metrics.go # prometheus definitions
|
||||
├── config.toml.example
|
||||
└── README.md # API contract for mailqueuer, key format, counting rules, ops
|
||||
```
|
||||
|
||||
Example config (`/usr/local/etc/migadu_counters/config.toml`; discovery order `-config`, `/etc/migadu_counters/config.toml`,
|
||||
`/usr/local/etc/migadu_counters/config.toml`):
|
||||
```toml
|
||||
addr = ":5555" # was :8095 before review round 1
|
||||
admin_addr = ":9095"
|
||||
auth_token = "…" # or MIGADU_COUNTERS_AUTH_TOKEN; same value in mailqueuer's config
|
||||
allowed_cidrs = [] # optional source-IP allowlist for /in, /out, /counters, e.g. ["10.0.0.0/8"]
|
||||
domains_cdb = "/usr/local/etc/rspamd/db/rspamd_domains_db"
|
||||
exempt = ["support@migadu.com", "contact@migadu.com"]
|
||||
count_junk = false
|
||||
|
||||
read_header_timeout = 5
|
||||
write_timeout = 30
|
||||
idle_timeout = 60
|
||||
handler_timeout = 4
|
||||
|
||||
[logging]
|
||||
output = "stderr" # daemon(8) -o captures it; or a file path (reopened on SIGHUP)
|
||||
format = "console" # or "json"
|
||||
level = "info"
|
||||
counting_output = "/var/log/migadu_counters/counting.log" # default: same as output; "off" disables
|
||||
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
|
||||
pool_size = 32
|
||||
timeout_ms = 500
|
||||
# Sentinel mode (production): writes → master, reads → replicas
|
||||
master_name = "mymaster"
|
||||
sentinels = ["10.0.0.10:26379", "10.0.0.11:26379", "10.0.0.12:26379"]
|
||||
sentinel_password = ""
|
||||
# Static mode (used when sentinels is empty)
|
||||
# write_addr = "127.0.0.1:6379"
|
||||
# read_addrs = ["127.0.0.1:6379"]
|
||||
|
||||
[notify]
|
||||
# One URL per level; leave empty to disable notifications for that level.
|
||||
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 = ""
|
||||
user = "…"
|
||||
password = "…" # or MIGADU_COUNTERS_NOTIFY_PASSWORD
|
||||
timeout_seconds = 5
|
||||
```
|
||||
|
||||
Metrics: `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}`, `_cdb_domains` (gauge), `_cdb_reloads_total{result}`.
|
||||
`/health`: 200 when the CDB is readable and the write client answers PING within 200 ms; else 503 with reason.
|
||||
|
||||
## Behaviour changes vs the Lua plugin (intentional)
|
||||
|
||||
- Separate `/in` and `/out` routes, one request per recipient, instead of rspamd's `MIGADU_FLOW` symbol.
|
||||
- Outgoing counted **per recipient** (Lua: once per message, skipped when all recipients were same-domain).
|
||||
Existing `*/o` limits therefore become stricter — a 50-recipient message costs 50; limits may need re-tuning
|
||||
before the web app's `outgoing_limit_exceeded!` is relied upon.
|
||||
- Local recipients of authenticated mail get incoming counted via `/out`.
|
||||
- Domain (`D`) level counted; notified only when `domain_url` is set.
|
||||
- Atomic increments; counters don't stop at the limit; no reject/soft-reject anywhere.
|
||||
- Notification on the exact crossing increment; re-fires after a web-app reset.
|
||||
- UTC day keys (matches the web app; Lua used server local time). Malformed CDB rows are skipped, not fatal.
|
||||
|
||||
## Deployment prerequisites (not part of this repo)
|
||||
|
||||
- An Ansible role in `ansible-freebsd3` cloned from `roles/rcptd`: user/group, `config.toml.j2`, the rc.d script
|
||||
(daemon(8) with pidfile `/var/run/migadu_counters/migadu_counters.pid`, log `/var/log/migadu_counters/…`),
|
||||
newsyslog entry, binary `migadu_counters-freebsd-amd64` + `counterctl-freebsd-amd64` in `/usr/local/bin`.
|
||||
- The CDB must exist on the host running the service (the agent delivers it to rspamd hosts today;
|
||||
stats-merger pulls the text variant) — same file, so a copy/pull job is enough.
|
||||
- Firewall: mailqueuer hosts → service port; service → Sentinel hosts :26379/:6379 and admin.migadu.com:443.
|
||||
- Secrets: bearer token shared with mailqueuer, Redis password, web app basic-auth credentials.
|
||||
|
||||
## Implementation order
|
||||
|
||||
0. Copy this plan into the repo as `plans/2026-10-13-migadu-counters.md` (requested; kept as the design record).
|
||||
1. `go.mod` deps, `Makefile`, `config.toml.example`, `internal/config` (structs, defaults, validation, logger from rcptd).
|
||||
2. `internal/domains` (+ tests with a temp CDB), `internal/watcher` (trimmed from rcptd).
|
||||
3. `internal/store` (sentinel/static clients, Incr pipeline, MGET) + miniredis tests, incl. crossing detection.
|
||||
4. `internal/counting` (request → increments, exempt/junk/unknown) + tests for the `/out` and `/in` examples.
|
||||
5. `internal/txlog` (text + json, golden tests), `internal/notify` (per-level URLs, retries) + httptest tests.
|
||||
6. `internal/api` types, `internal/server` (`/in`, `/out`, `/counters`, admin), `internal/metrics`.
|
||||
7. `cmd/migadu_counters/main.go` (wiring, signals, shutdown), `cmd/counterctl/main.go` (+ tests).
|
||||
8. `README.md` (API contract for mailqueuer, key format, counting rules, ops), manual verification below.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `go vet ./... && go test -race ./...`
|
||||
- `domains`: parse/override/unlimited/malformed rows via a temp CDB (`cdb.Create`); unknown domain.
|
||||
- `counting`: the `/out` example (two calls → out +2 per level, y in +1 per level); external-only recipient;
|
||||
sender domain not in CDB (only recipient counted); exempt sender/recipient → nothing; `/in` local/unknown/junk.
|
||||
- `store` + miniredis: key format, TTL set, DB 0, crossing fires exactly once (limit 3: 3rd increment
|
||||
`reached`, 4th not; after `DEL` the next crossing fires again); concurrent increments → one crossing;
|
||||
read-side round-robin / fallback; Redis down → 503.
|
||||
- `notify`: each level hits its own URL, empty URL skips, all vars escaped (`@` → `%40`), retries then failure metric.
|
||||
- `txlog`: golden-file tests with an injected clock for the `/out` example above (local + external recipient,
|
||||
one level reached), an `/in` request, each skip reason, a Redis failure, and the `json` format; `Reopen`
|
||||
after the file is moved writes to the new path.
|
||||
- `api/validate`: table tests per field and route (missing, empty, too long, two `@`, whitespace, control
|
||||
chars, uppercase → lowercased, punycode accepted, `mail_from` empty on `/in`, `trace_id` sanitized).
|
||||
- `server/middleware`: one test per step in chain order — 405, 413 (65 KB body), 401 (missing/wrong/`Basic`),
|
||||
403 (`allowed_cidrs` miss) vs pass, 415, 400 (syntax error, two objects, unknown field *accepted*), 503
|
||||
(handler timeout with a blocked Redis), 500 (panic recovered, daemon still serving); each rejection leaves
|
||||
Redis untouched (miniredis key count unchanged) and increments `requests_total{status}`.
|
||||
- `server`: 401, 400, 200 with `counted` payload; `/counters` with address/domain, default date = today UTC,
|
||||
explicit `date`, future date → 400, malformed date → 400, unmanaged domain → `managed:false` + null limits;
|
||||
notify targets are per-level `httptest.Server`s.
|
||||
- `counterctl`: date parsing/validation (valid ISO, malformed, future, > 31 days warning), exactly-one-of
|
||||
`--address`/`--domain`, `addr` → URL normalization, table rendering against a fixed API response
|
||||
(`httptest.Server`), `--json` passthrough, exit codes.
|
||||
2. Manual: local Redis in static mode, CDB built from a sample line (`cdb -m -c`, or the test helper),
|
||||
`go run ./cmd/migadu_counters -config config.toml.example` with `org_url`/`address_url` pointed at a local
|
||||
listener. `curl -XPOST localhost:8095/out -H 'Authorization: Bearer …' -d '{…}'` repeatedly; check
|
||||
`redis-cli -n 0 KEYS 'cnt/*'`, `GET /counters?address=…`, exactly one notification per level on crossing,
|
||||
`redis-cli DEL` the A key → crossing notifies again; edit the CDB + `kill -HUP` → limits reload.
|
||||
`go run ./cmd/counterctl --config config.toml.example --address x@example.com` shows the same numbers as
|
||||
`redis-cli`; `--date <yesterday>` shows zeros/older data; `--date 2099-01-01` is rejected.
|
||||
`tail -f counting.log` while sending the curl requests shows the full block per request, and
|
||||
`grep '<trace_id>' counting.log` returns one transaction. Daemon behaviour: `kill -TERM` exits 0 after
|
||||
draining; with `logging.output`/`counting_output` set to files, `kill -HUP` reopens them (rotate with `mv`
|
||||
first and confirm new lines land in the new files); `make freebsd` produces the two static binaries.
|
||||
Optionally a local Sentinel (1 master + 1 replica) to confirm reads hit the replica.
|
||||
Reference in New Issue
Block a user