From 223f3cdc42b763202df09cea0408c6ac591856b7 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Tue, 14 Jul 2026 21:57:38 +0300 Subject: [PATCH] Phase 8: level-2 differentiated rate limits (spec 7.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The journal-milter, until now a pure monitor, now refuses a message with a 4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per- application limit is exceeded. Key is the client IP; the count is COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the limit applies only when a non-empty IP binding matches the client (empty binding => level-1 only, per spec 7.4). Enforcement is fail-open on the milter's own errors — a limiter malfunction never blocks mail, and Postfix's level-1 anvil limit stays the independent backstop. Refused messages are recorded in send_log with status "rejected" for UI visibility. - store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/ delete for the panel, name/login-keyed lookup + windowed distinct-message count for the milter, DeleteRateLimitsForDomain. No migration — the rate_limits table has existed since Phase 2. - milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected. - web: server-side validated IP/ceiling/window forms on the domain page and per application; routes POST /domains/{id}/ratelimit and /applications/{aid}/ratelimit. Milter reads rows live, so no reload. - domain/app services clear limits on deletion (rate_limits has no FK cascade). Unit tests + container e2e (p8) green: refusal on both scopes, unregistered IP ignored, fail-open with the panel stopped. Co-Authored-By: Claude Opus 4.8 --- docs/progress.md | 14 +- internal/app/service.go | 29 +++ internal/domain/service.go | 31 ++++ internal/milter/milter.go | 26 ++- internal/milter/milter_test.go | 169 ++++++++++++++++- internal/milter/ratelimit.go | 70 +++++++ internal/store/ratelimits.go | 214 ++++++++++++++++++++++ internal/store/ratelimits_test.go | 178 ++++++++++++++++++ internal/store/sendlog.go | 22 +++ internal/web/handlers_apps.go | 89 +++++++-- internal/web/handlers_ratelimit.go | 155 ++++++++++++++++ internal/web/templates/domain_detail.html | 55 ++++++ internal/web/web.go | 2 + 13 files changed, 1030 insertions(+), 24 deletions(-) create mode 100644 internal/milter/ratelimit.go create mode 100644 internal/store/ratelimits.go create mode 100644 internal/store/ratelimits_test.go create mode 100644 internal/web/handlers_ratelimit.go diff --git a/docs/progress.md b/docs/progress.md index 84e1e0d..2122677 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -46,13 +46,24 @@ ## Текущее состояние -- **Текущая фаза:** 7 ✅ **закрыта** → следующая **Фаза 8** (дифференцированные лимиты, rate limit уровень 2) на **Opus** (логика лимитов в milter — риск-критично). +- **Текущая фаза:** 8 ✅ **закрыта** → следующая **Фаза 9** (бэкап/restore + экспорт/импорт домена) на **Opus** (целостность данных, версионирование). - **Ключевая находка Фазы 6 (исправлена):** go-milter хранит имена макросов **как их шлёт Postfix** — многосимвольные имена приходят в фигурных скобках (`{auth_authen}`, `{client_addr}`), односимвольные — голыми (`i`). Спайк Фазы 0 без SASL этого не увидел (`auth_authen` был пуст «и так»). Первый прогон в контейнере дал пустой `app_login`; фикс — brace-толерантный `macro(m,name)` (пробует голый ключ, затем `{name}`). Зафиксировано в памяти [[milter-implementation-facts]]. - **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`). - **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@mixdelta.ru → selfpost@mixeme.ru` доставлено и принято `mc.mixfed.ru`, заголовок `Authentication-Results: dkim=pass (d=mixdelta.ru s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у mixdelta.ru только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна. - **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mc.mixfed.ru` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `mixdelta.ru` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix. - **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / mixdelta.ru id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `mixdelta.ru` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить). +### Сделано в Фазе 8 +- **Уровень 2 rate-limit в journal-milter** (ТЗ 7.4): milter, бывший чистым монитором, теперь **отклоняет** письмо `4xx` (`milter.RespTempFail` = 451) при превышении дифференцированного лимита. Проверка на стадии **MAIL FROM** — самой ранней, где известны и домен (из `From`), и приложение (SASL-логин), — до предложения получателей. + - **Когда лимит применяется:** только если у домена/приложения заданы непустой список IP **и** потолок сообщений **и** окно, **и** client IP входит в этот список (`RateLimit.Active()` + `AllowsIP`). Пустая IP-привязка → уровень 2 не применяется (ТЗ 7.4: «оставить пустой → не применяется»); IP вне списка → остаётся только уровень 1 (anvil). Ключ — client IP из `Connect()` (ТЗ 7.4). + - **Счёт — сообщения, не получатели:** `COUNT(DISTINCT queue_id)` в скользящем окне (письмо на много получателей = одно письмо, как у уровня 1). Переиспользует `send_log` (ТЗ 7.4), исключает строки `rejected`. + - **Fail-open на собственных ошибках milter'а:** любая ошибка БД при lookup/count логируется и трактуется как «не превышено» — сбой лимитера никогда не блокирует почту; уровень 1 (anvil) не зависит от milter и остаётся backstop'ом (ТЗ 7.4). Отклоняет **только** чистый `count >= limit`. + - **Отклонённые письма** пишутся в `send_log` со статусом `rejected` (`InsertRejected`) для видимости в UI (ТЗ 7.4, опционально) — без queue-id/получателя (отклонено до постановки в очередь). +- **Store** (`internal/store/ratelimits.go`, миграция не нужна — таблица `rate_limits` заведена ещё в Фазе 2): `RateLimit`-тип (+`Active`/`AllowsIP` через `net.ParseIP`+`.Equal`), `GetRateLimit`/`SetRateLimit`(upsert по `UNIQUE(scope,ref_id)`)/`DeleteRateLimit` по id (панель), `RateLimit(scope,ref)` по имени домена/логину (milter, через JOIN), `CountMessages(scope,ref,since)` (distinct queue_id, исключая `rejected`), `DeleteRateLimitsForDomain` (домен + его приложения одним запросом). `StatusRejected`/`InsertRejected` в `sendlog.go`. IP хранятся как canonical CSV. Значения только как SQLite-параметры (не в конфиг-файлы) — инъекций нет. +- **Каскад:** `rate_limits.ref_id` — простое число без FK, поэтому очистка вручную: `domain.Service.Delete` зовёт `DeleteRateLimitsForDomain` **до** каскада приложений; `app.Service.Delete` зовёт `DeleteRateLimit`. (AUTOINCREMENT не переиспользует id, так что осиротевшие строки инертны, но чистим для порядка.) +- **Веб/UI** (`internal/web/handlers_ratelimit.go`, `handlers_apps.go`, шаблон `domain_detail.html`): серверная валидация (ТЗ 7.6.2) — каждый IP через `net.ParseIP`, потолок/окно — положительные int, окно по умолчанию 3600 (`RATE_LIMIT_WINDOW_SECONDS`); пустой список IP или явный `clear=1` → удаление лимита. Карточка «Sending rate limit (domain)» на странице домена + `
Rate limit` на каждом приложении (prefill из сохранённого состояния, кнопка «Remove limit», статус active/inactive). Ошибки валидации — баннером `RateLimitErr`. Роуты `POST /domains/{id}/ratelimit`, `POST /applications/{aid}/ratelimit`. Milter читает строку живьём — **reload не нужен**. Сервис-обёртки `RateLimit`/`SaveRateLimit`/`ClearRateLimit` на domain и app сервисах. +- **Проверено на сервере** (selfpost.mixfed.ru, контейнер `p8`): `gofmt`/`vet`/`test` зелёные (юниты: store set/get/upsert/delete, by-name/login, distinct+windowed count с исключением rejected, delete-for-domain, Active/AllowsIP incl. IPv6-форма; milter: reject при домен/app превышении, allow под лимитом, unregistered-IP игнор, инертный без потолка, fail-open на lookup/count-ошибке, no-IP-сессия). Контейнерный e2e (аутентифицированный SMTPS 465 изнутри контейнера, client=127.0.0.1): лимит `max=2` → 3-е письмо `SENDER-REFUSED 451`, ровно 2 в очереди, 1 строка `rejected`; **то же на уровне домена**; **unregistered IP** (198.51.100.1) → уровень 2 не применяется (оба письма прошли при `max=1`); **fail-open** — панель остановлена (`supervisorctl stop panel`) при `max=1` → почта принята; UI обеих форм рендерится. Уровень 1 (anvil) не тронут. + ### Сделано в Фазе 7 - **Три экрана мониторинга** (`internal/web/handlers_monitor.go` + шаблоны `sendlog.html`/`queue.html`/`logtail.html` + фрагменты `sendlog_rows.html`/`queue_body.html`/`logtail_body.html`): - **Журнал отправки** (`/sendlog`): таблица время/домен/приложение/From/To/Subject/статус, серверные фильтры по домену и логину приложения (`WHERE` через `store.SendLogFilter`, параметризовано), пагинация (50/страница, `LIMIT/OFFSET`, счётчик страниц через `CountSendLog`), HTMX-polling каждые 5с (`hx-trigger="every 5s"` на самообновляющемся `
`, `hx-swap="outerHTML"` — ответ фрагмента несёт те же hx-атрибуты, поэтому поллинг не обрывается). Вывод экранируется автоматически `html/template` (subject с `