First commit Go counter service
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# migadu_counters
|
||||
|
||||
Real-time mail counting for Migadu. mailqueuer tells it about every ingested
|
||||
recipient; it increments per-address / per-domain / per-organisation daily
|
||||
counters in Redis (the keys the web app already charts) and, when an increment
|
||||
reaches a configured limit, notifies the web app, which decides what happens to
|
||||
the customer. It replaces the rspamd Lua plugin `migadu_counters.lua` and the
|
||||
ledger-derived quota in stats-merger. It returns no verdicts and enforces
|
||||
nothing itself.
|
||||
|
||||
Design record: `plans/2026-10-13-migadu-counters.md`.
|
||||
|
||||
```
|
||||
sender ──SMTP──▶ mizu ──POST /ingest──▶ mailqueuer
|
||||
│ per recipient, after a successful publish:
|
||||
│ 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>
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
rspamd_domains_db ───▶│ migadu_counters (:5555, admin :9095)│
|
||||
(domain → org|limits) │ validate → 202 → queue → workers: │
|
||||
│ lookup domain → MULTI/INCR → notify │──▶ GET admin.migadu.com/api/limits
|
||||
└──────────────────┬──────────────────┘ (on the increment that crosses a limit)
|
||||
▼
|
||||
Redis Sentinel: writes → master, reads → replicas
|
||||
DB 0: cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o> (TTL 31 d)
|
||||
|
||||
counterctl --address x@y [--date YYYY-MM-DD] ──GET /counters──▶ migadu_counters ──▶ replicas
|
||||
```
|
||||
|
||||
## API (for mailqueuer)
|
||||
|
||||
Every request: `Authorization: Bearer <auth_token>` (same value in mailqueuer's
|
||||
config; the daemon refuses to start without one), `Content-Type:
|
||||
application/json`, body ≤ 64 KB, one JSON object. Unknown fields are ignored.
|
||||
mailqueuer never waits for or acts on the reply.
|
||||
|
||||
Counting is **decoupled from the request**: `/in` and `/out` validate the body,
|
||||
enqueue a job and answer `202` at once; a worker pool does the CDB lookup, the
|
||||
Redis transaction and the notification under its own time budget, so a client
|
||||
that hangs up or times out can no longer cancel a count. The counted values
|
||||
appear in the counting log and in `GET /counters`, not in the reply.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `POST /out` | `{"trace_id":"…","auth_user":"x@example.com","mail_from":"x@example.com","recipient":"y@example.com"}` — one call per recipient of an authenticated submission. Sender A/D/O **outgoing** +1 (if the sender's domain is hosted), recipient A/D/O **incoming** +1 (if the recipient's domain is hosted). `auth_user` and `recipient` are validated independently: whichever side is a usable address is counted, the other is logged as `not usable`; `400` only when neither is. A support/token login `user@dom@SUFFIX` counts under `user@dom` (the suffix is logged). |
|
||||
| `POST /in` | `{"trace_id":"…","mail_from":"s@gmail.com","recipient":"y@example.com","junk":false}` — one call per recipient of inbound mail. Recipient A/D/O **incoming** +1 if hosted. `junk:true` is skipped unless `count_junk = true`. `mail_from` may be `""` (bounce) or anything mizu accepted — it only feeds the exempt check and never rejects the request. |
|
||||
| `GET /counters?address=…` or `?domain=…` `[&date=YYYY-MM-DD]` | Today's (UTC) or a given day's values and limits, read from the replicas. |
|
||||
|
||||
Responses: `202 {"accepted":true}` (validated and queued; skips such as
|
||||
exempt/junk/unknown domain are decided by the worker and appear only in the
|
||||
counting log), `4xx {"error":"…"}` from the validation layer (`405` method,
|
||||
`413` size, `401` token, `403` source IP, `415` content type, `400` JSON /
|
||||
required fields / e-mail syntax), `503 {"error":"queue full"}` when the
|
||||
counting queue (`queue_size`) is full — the drop is counted in
|
||||
`dropped_total{route}` and logged — or `503` on a handler timeout of the
|
||||
synchronous part.
|
||||
|
||||
Rules: addresses are trimmed and lowercased, Unicode domains are converted to
|
||||
punycode (the CDB key), and a plus-address tag is dropped so `b+tag@…` counts
|
||||
against the mailbox `b@…` (the counter the web app reads); mail to or from an exempt address (`support@migadu.com`,
|
||||
`contact@migadu.com`) is never counted; a domain that is not in
|
||||
`rspamd_domains_db` is not hosted here and not counted; the sender of `/in`
|
||||
mail is never counted; there is no reject/soft-reject — counting continues past
|
||||
the limit.
|
||||
|
||||
## Limits and keys
|
||||
|
||||
Limits come from the rspamd domains CDB (tinycdb, built by the agent):
|
||||
`example.com → 1049|O/i=200,O/o=20,D/i=200,D/o=20,A/i=200,A/o=20,info@/o=5`.
|
||||
`<local>@/<flow>` overrides the `A/<flow>` default for one mailbox; a missing
|
||||
key means unlimited. The file is reloaded on SIGHUP and automatically when it
|
||||
changes on disk (atomic `mv` is detected); a file that cannot be read or is
|
||||
empty is rejected and the current table kept. A row that exists but cannot be
|
||||
parsed (non-numeric org id) is treated as "not hosted" for that domain and
|
||||
warned about in the app log; the other side of the request is still counted.
|
||||
Always replace the file with
|
||||
`mv` (as the agent's `cdb -c` + `mv` do) — rewriting it in place truncates the
|
||||
inode the daemon holds open and breaks lookups until the next reload.
|
||||
|
||||
Redis keys are exactly the web app's (`migadu_web` `core/redis_sentinel.ex`):
|
||||
`cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o>` in **DB 0**, UTC day, TTL 31 days,
|
||||
`INCR`+`EXPIRE` in one MULTI/EXEC. The web app may `DEL` a day key to reset a
|
||||
customer; the next crossing notifies again.
|
||||
|
||||
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)`.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Counting transaction log
|
||||
|
||||
Every request writes one block in the shape of the old Lua plugin's log lines
|
||||
(rspamd's task id replaced by the trace id) to `logging.counting_output`:
|
||||
|
||||
```
|
||||
2026-10-06 01:12:06.412 <ad8525> out; request auth_user=noah@example.com recipient=y@example.org mail_from=noah@example.com
|
||||
2026-10-06 01:12:06.412 <ad8525> out; local domain: example.com account_id=116681
|
||||
2026-10-06 01:12:06.412 <ad8525> out; domain: example.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@example.com/d/2026-10-06/o]) => 100
|
||||
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@example.com => count=12 limit=100 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@example.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?…"
|
||||
```
|
||||
|
||||
`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
|
||||
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.
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
migadu_counters -config /usr/local/etc/migadu_counters/config.toml # default paths: /etc/…, /usr/local/etc/…
|
||||
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
|
||||
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
|
||||
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
|
||||
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,
|
||||
reads to a replica (falling back to the master when none is known); without
|
||||
`sentinels` the static `write_addr`/`read_addrs` are used.
|
||||
|
||||
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`),
|
||||
`_dropped_total{route}`, `_queue_depth`, `_queue_wait_seconds`, `_cdb_domains`,
|
||||
`_cdb_reloads_total{result}`.
|
||||
|
||||
## counterctl
|
||||
|
||||
```
|
||||
counterctl --address me@example.com # today, UTC
|
||||
counterctl --address me@example.com --date 2026-10-12
|
||||
counterctl --address me@example.com --out # outgoing columns only (--in: incoming)
|
||||
counterctl --domain example.com --json
|
||||
```
|
||||
|
||||
Reads `addr`/`auth_token` from the daemon's config (`--config`, default paths,
|
||||
`MIGADU_COUNTERS_AUTH_TOKEN` overrides the token; `--url` overrides the
|
||||
address) and calls `GET /counters`. Output:
|
||||
|
||||
```
|
||||
me@example.com date 2026-10-13 (UTC) domain example.com org 1234
|
||||
LEVEL ID IN LIMIT OUT LIMIT
|
||||
A me@example.com 12 500 200! 200
|
||||
D example.com 40 1000 9 -
|
||||
O 1234 120 5000 30 1000
|
||||
```
|
||||
|
||||
`!` marks a value at or above its limit, `-` an unlimited level. `--in` /
|
||||
`--out` restrict the table to one flow (`LEVEL ID IN LIMIT` or `LEVEL ID OUT
|
||||
LIMIT`; both or neither show the full table; `--json` ignores them). `--date` takes
|
||||
an ISO 8601 calendar date (`YYYY-MM-DD`); future dates are refused and dates
|
||||
older than 31 days are warned about (keys have expired). Exit 0 on success, 1
|
||||
on any error.
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
make build # bin/migadu_counters, bin/counterctl
|
||||
make test # go test -race ./... (tests use test_files/rspamd_domains_db.cdb and miniredis)
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
curl -s -XPOST localhost:5555/out -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
|
||||
-d '{"trace_id":"t1","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}' # -> {"accepted":true}
|
||||
redis-cli -n 0 KEYS 'cnt/*'
|
||||
go run ./cmd/counterctl -config config.toml --address a@yagolivas.com --out
|
||||
```
|
||||
Reference in New Issue
Block a user