Phase 8: level-2 differentiated rate limits (spec 7.4)
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 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -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]].
|
- **Ключевая находка Фазы 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`).
|
- **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`).
|
||||||
- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
|
- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
|
||||||
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
|
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
|
||||||
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
|
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (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)» на странице домена + `<details>Rate limit` на каждом приложении (prefill из сохранённого состояния, кнопка «Remove limit», статус active/inactive). Ошибки валидации — баннером `RateLimitErr`. Роуты `POST /domains/{id}/ratelimit`, `POST /applications/{aid}/ratelimit`. Milter читает строку живьём — **reload не нужен**. Сервис-обёртки `RateLimit`/`SaveRateLimit`/`ClearRateLimit` на domain и app сервисах.
|
||||||
|
- **Проверено на сервере** (selfpost.example.com, контейнер `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
|
### Сделано в Фазе 7
|
||||||
- **Три экрана мониторинга** (`internal/web/handlers_monitor.go` + шаблоны `sendlog.html`/`queue.html`/`logtail.html` + фрагменты `sendlog_rows.html`/`queue_body.html`/`logtail_body.html`):
|
- **Три экрана мониторинга** (`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"` на самообновляющемся `<div>`, `hx-swap="outerHTML"` — ответ фрагмента несёт те же hx-атрибуты, поэтому поллинг не обрывается). Вывод экранируется автоматически `html/template` (subject с `<script>` проверен — рендерится как `<script>`).
|
- **Журнал отправки** (`/sendlog`): таблица время/домен/приложение/From/To/Subject/статус, серверные фильтры по домену и логину приложения (`WHERE` через `store.SendLogFilter`, параметризовано), пагинация (50/страница, `LIMIT/OFFSET`, счётчик страниц через `CountSendLog`), HTMX-polling каждые 5с (`hx-trigger="every 5s"` на самообновляющемся `<div>`, `hx-swap="outerHTML"` — ответ фрагмента несёт те же hx-атрибуты, поэтому поллинг не обрывается). Вывод экранируется автоматически `html/template` (subject с `<script>` проверен — рендерится как `<script>`).
|
||||||
@@ -151,4 +162,5 @@
|
|||||||
- **Фаза 4** (2026-07-12, Opus) — приложения + SASL + привязка к домену: учётки в `sasldb2` через `saslpasswd2` (пароль по stdin, логин whitelisted argv, без shell — 7.6.3), генерируемый пароль показывается один раз (7.6.1), режим адресов wildcard/list с серверной проверкой принадлежности адреса домену (7.6.2), генерация `smtpd_sender_login_maps` (many-to-one слияние, injection-safe — 7.6.4), CRUD приложений + перевыпуск пароля + каскад при удалении домена (очистка `sasldb2` + пересборка карты). **Исправлен reload Postfix:** `signal HUP` не доходит до форкнутого master → одноразовая supervisord-программа `postfix-reload` (настоящий `postfix reload` от root без привилегий панели). `postfix` в группе `selfpost`, `/data/sasl`+`/data/postfix` под setgid. Новые пакеты `internal/app`, `internal/postfix`. Юнит-тесты + контейнерный e2e (весь жизненный цикл, каскад, персистентность, реальный reload по `mail.log`) зелёные.
|
- **Фаза 4** (2026-07-12, Opus) — приложения + SASL + привязка к домену: учётки в `sasldb2` через `saslpasswd2` (пароль по stdin, логин whitelisted argv, без shell — 7.6.3), генерируемый пароль показывается один раз (7.6.1), режим адресов wildcard/list с серверной проверкой принадлежности адреса домену (7.6.2), генерация `smtpd_sender_login_maps` (many-to-one слияние, injection-safe — 7.6.4), CRUD приложений + перевыпуск пароля + каскад при удалении домена (очистка `sasldb2` + пересборка карты). **Исправлен reload Postfix:** `signal HUP` не доходит до форкнутого master → одноразовая supervisord-программа `postfix-reload` (настоящий `postfix reload` от root без привилегий панели). `postfix` в группе `selfpost`, `/data/sasl`+`/data/postfix` под setgid. Новые пакеты `internal/app`, `internal/postfix`. Юнит-тесты + контейнерный e2e (весь жизненный цикл, каскад, персистентность, реальный reload по `mail.log`) зелёные.
|
||||||
- **Фаза 5** (2026-07-12…13, Opus) — полный исходящий релей Postfix: `smtps` 465 (wrapper TLS) как основной + опциональный `submission` 587 (STARTTLS), SASL (`cyrus`/`sasldb2`, реалм через пустой `smtpd_sasl_local_domain` + `myhostname`), привязка отправителя (`smtpd_sender_login_maps`+`reject_sender_login_mismatch`), без open relay (только по кредам, нет `permit_mynetworks`), исходящая доставка (MX-lookup, TLS may), rate-limit L1 (`anvil`), milter-цепочка с per-milter действиями (OpenDKIM tempfail / journal accept). Конфиг генерируется из env в `postfix-config.sh` (вызов из entrypoint). Два инфра-фикса на сервере: `postconf -F '*/*/chroot=n'` (chroot ломал DNS доставки) и права milter-сокетов (группа `selfpost`+setgid, `chmod 0660` на journal-сокет). **Реальная доставка подтверждена:** `test.example.org → selfpost@mixeme.ru`, `dkim=pass`+`spf=pass` в `Authentication-Results` (по IMAP). Коммиты `b2692e4` (релей), `ec4d4b9`/этот (доки).
|
- **Фаза 5** (2026-07-12…13, Opus) — полный исходящий релей Postfix: `smtps` 465 (wrapper TLS) как основной + опциональный `submission` 587 (STARTTLS), SASL (`cyrus`/`sasldb2`, реалм через пустой `smtpd_sasl_local_domain` + `myhostname`), привязка отправителя (`smtpd_sender_login_maps`+`reject_sender_login_mismatch`), без open relay (только по кредам, нет `permit_mynetworks`), исходящая доставка (MX-lookup, TLS may), rate-limit L1 (`anvil`), milter-цепочка с per-milter действиями (OpenDKIM tempfail / journal accept). Конфиг генерируется из env в `postfix-config.sh` (вызов из entrypoint). Два инфра-фикса на сервере: `postconf -F '*/*/chroot=n'` (chroot ломал DNS доставки) и права milter-сокетов (группа `selfpost`+setgid, `chmod 0660` на journal-сокет). **Реальная доставка подтверждена:** `test.example.org → selfpost@mixeme.ru`, `dkim=pass`+`spf=pass` в `Authentication-Results` (по IMAP). Коммиты `b2692e4` (релей), `ec4d4b9`/этот (доки).
|
||||||
- **Фаза 6** (2026-07-13, Opus) — journal-milter + обновление статусов Send Log (наивысший риск ТЗ 7.3): milter на go-milter v0.4.1 (запись `send_log` на пару queue-id/получатель на EOM, строго fail-open — колбэки только Continue/Accept), log-tailer с ротацией `mail.log` (парс `sent/deferred/bounced/expired` → апдейт по queue-id+получатель), retention (`SEND_LOG_RETENTION_DAYS`=90, чистка при старте+каждые 6ч), bounded milter-таймауты (15/15/30с) для fail-open при зависании. Store открывается один раз и шарится ролями. **Найден и исправлен** пустой `app_login`: имена макросов приходят в фигурных скобках (`{auth_authen}`) — brace-толерантный `macro()`. Юниты + контейнерный e2e зелёные; **fail-open проверен дважды** (недоступность и зависание), retention проверен. Новая зависимость go-milter (BSD-2).
|
- **Фаза 6** (2026-07-13, Opus) — journal-milter + обновление статусов Send Log (наивысший риск ТЗ 7.3): milter на go-milter v0.4.1 (запись `send_log` на пару queue-id/получатель на EOM, строго fail-open — колбэки только Continue/Accept), log-tailer с ротацией `mail.log` (парс `sent/deferred/bounced/expired` → апдейт по queue-id+получатель), retention (`SEND_LOG_RETENTION_DAYS`=90, чистка при старте+каждые 6ч), bounded milter-таймауты (15/15/30с) для fail-open при зависании. Store открывается один раз и шарится ролями. **Найден и исправлен** пустой `app_login`: имена макросов приходят в фигурных скобках (`{auth_authen}`) — brace-толерантный `macro()`. Юниты + контейнерный e2e зелёные; **fail-open проверен дважды** (недоступность и зависание), retention проверен. Новая зависимость go-milter (BSD-2).
|
||||||
|
- **Фаза 8** (2026-07-14, Opus) — дифференцированные лимиты (rate limit уровень 2, ТЗ 7.4): journal-milter из чистого монитора стал отклонять письмо `4xx` (`RespTempFail` 451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт — `COUNT(DISTINCT queue_id)` в скользящем окне по `send_log`, применяется только при непустой IP-привязке (иначе только уровень 1). Строго **fail-open** на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутся `send_log` статусом `rejected` для UI. Store `internal/store/ratelimits.go` (таблица `rate_limits` уже была с Фазы 2 — миграции нет), панель-формы на домене и приложении с серверной валидацией IP/чисел (ТЗ 7.6.2), очистка лимитов при каскадном удалении. Юниты + контейнерный e2e (`p8`): реджект на обоих уровнях, unregistered-IP игнор, fail-open при остановке панели — зелёные. Все критерии «Готово когда» Фазы 8 выполнены.
|
||||||
- **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
|
- **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
|
||||||
|
|||||||
@@ -140,9 +140,38 @@ func (s *Service) Delete(id int64) error {
|
|||||||
if err := s.sasl.Delete(a.Login); err != nil {
|
if err := s.sasl.Delete(a.Login); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Drop the application's level-2 limit, if any (spec 7.4); rate_limits has no
|
||||||
|
// cascade of its own.
|
||||||
|
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.Resync()
|
return s.Resync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimit returns the application-level differentiated rate limit (spec 7.4),
|
||||||
|
// and whether one is configured, for the application's edit form.
|
||||||
|
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||||
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
||||||
|
// the IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload
|
||||||
|
// is needed.
|
||||||
|
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
|
Scope: store.RateLimitScopeApp,
|
||||||
|
RefID: appID,
|
||||||
|
AllowedIPs: ips,
|
||||||
|
MaxMessages: maxMessages,
|
||||||
|
WindowSeconds: windowSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRateLimit removes the application-level rate limit (spec 7.4).
|
||||||
|
func (s *Service) ClearRateLimit(appID int64) error {
|
||||||
|
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||||
|
}
|
||||||
|
|
||||||
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||||
// domain. It must be called before the domain's registry rows are cascade-
|
// domain. It must be called before the domain's registry rows are cascade-
|
||||||
// deleted, while the logins are still known (spec 7.2.4). The registry rows and
|
// deleted, while the logins are still known (spec 7.2.4). The registry rows and
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ func (s *Service) Delete(id int64) error {
|
|||||||
if err := s.apps.PurgeDomainSASL(id); err != nil {
|
if err := s.apps.PurgeDomainSASL(id); err != nil {
|
||||||
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
|
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
|
||||||
}
|
}
|
||||||
|
// Drop the domain's own level-2 limit and those of its applications while the
|
||||||
|
// application rows still exist (the cleanup query joins them). rate_limits has
|
||||||
|
// no cascade of its own (ref_id is a plain integer, spec 7.4/9).
|
||||||
|
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
|
||||||
|
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
|
||||||
|
}
|
||||||
if err := s.store.DeleteDomain(id); err != nil {
|
if err := s.store.DeleteDomain(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,6 +128,31 @@ func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
|||||||
return s.odk.Record(d.Name, d.DKIMSelector)
|
return s.odk.Record(d.Name, d.DKIMSelector)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimit returns the domain-level differentiated rate limit (spec 7.4), and
|
||||||
|
// whether one is configured, for the domain's edit form.
|
||||||
|
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
||||||
|
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||||
|
// IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload is
|
||||||
|
// needed.
|
||||||
|
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||||
|
return s.store.SetRateLimit(store.RateLimit{
|
||||||
|
Scope: store.RateLimitScopeDomain,
|
||||||
|
RefID: domainID,
|
||||||
|
AllowedIPs: ips,
|
||||||
|
MaxMessages: maxMessages,
|
||||||
|
WindowSeconds: windowSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
||||||
|
// only (spec 7.4).
|
||||||
|
func (s *Service) ClearRateLimit(domainID int64) error {
|
||||||
|
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
|
||||||
|
}
|
||||||
|
|
||||||
// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM.
|
// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM.
|
||||||
// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path
|
// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path
|
||||||
// if the tables ever drift from the database.
|
// if the tables ever drift from the database.
|
||||||
|
|||||||
@@ -16,16 +16,22 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-milter"
|
"github.com/emersion/go-milter"
|
||||||
|
|
||||||
"codeberg.org/mix/selfpost/internal/store"
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recorder persists queued send-log entries. *store.Store satisfies it; tests
|
// Store is the persistence the milter needs on the receive path: recording
|
||||||
// substitute a fake.
|
// accepted messages (spec 7.3) and, for level-2 rate limiting (spec 7.4),
|
||||||
type Recorder interface {
|
// looking up the configured limits and counting recent messages. *store.Store
|
||||||
|
// satisfies it; tests substitute a fake.
|
||||||
|
type Store interface {
|
||||||
InsertQueued(e store.SendLogEntry) error
|
InsertQueued(e store.SendLogEntry) error
|
||||||
|
InsertRejected(e store.SendLogEntry) error
|
||||||
|
RateLimit(scope, ref string) (store.RateLimit, bool, error)
|
||||||
|
CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// session accumulates the fields of one message as the milter callbacks fire.
|
// session accumulates the fields of one message as the milter callbacks fire.
|
||||||
@@ -37,7 +43,7 @@ type Recorder interface {
|
|||||||
// MailFrom (the start of every transaction).
|
// MailFrom (the start of every transaction).
|
||||||
type session struct {
|
type session struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
rec Recorder
|
rec Store
|
||||||
|
|
||||||
clientIP string // captured once per connection
|
clientIP string // captured once per connection
|
||||||
|
|
||||||
@@ -59,12 +65,20 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
|
|||||||
|
|
||||||
// MailFrom starts a new message: reset per-message state, then capture the
|
// MailFrom starts a new message: reset per-message state, then capture the
|
||||||
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
|
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
|
||||||
// macros).
|
// macros). This is also the earliest stage where both the sending domain (from
|
||||||
|
// the sender) and the application (the login) are known, so the level-2 rate
|
||||||
|
// limit is enforced here: over the limit, the message is refused with a 4xx
|
||||||
|
// tempfail before recipients are even offered (spec 7.4). Enforcement is
|
||||||
|
// fail-open — see overLimit.
|
||||||
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
s.login = macro(m, "auth_authen")
|
s.login = macro(m, "auth_authen")
|
||||||
s.rcpts = nil
|
s.rcpts = nil
|
||||||
s.subject = ""
|
s.subject = ""
|
||||||
|
if s.overLimit() {
|
||||||
|
s.recordRejected()
|
||||||
|
return milter.RespTempFail, nil
|
||||||
|
}
|
||||||
return milter.RespContinue, nil
|
return milter.RespContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +166,7 @@ func domainOf(addr string) string {
|
|||||||
|
|
||||||
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
|
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
|
||||||
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
|
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
|
||||||
func Serve(ctx context.Context, ln net.Listener, rec Recorder) error {
|
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
NewMilter: func() milter.Milter { return &session{rec: rec} },
|
NewMilter: func() milter.Milter { return &session{rec: rec} },
|
||||||
Actions: 0, // read-only: we make no message modifications
|
Actions: 0, // read-only: we make no message modifications
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-milter"
|
"github.com/emersion/go-milter"
|
||||||
|
|
||||||
@@ -11,10 +12,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// fakeRecorder captures inserts and can be made to fail, to prove the milter
|
// fakeRecorder captures inserts and can be made to fail, to prove the milter
|
||||||
// swallows recorder errors and still accepts the message.
|
// swallows recorder errors and still accepts the message. By default it reports
|
||||||
|
// no configured rate limit, so the level-2 check is inert unless a test sets
|
||||||
|
// limits (see fakeRecorder fields).
|
||||||
type fakeRecorder struct {
|
type fakeRecorder struct {
|
||||||
entries []store.SendLogEntry
|
entries []store.SendLogEntry
|
||||||
|
rejected []store.SendLogEntry
|
||||||
fail bool
|
fail bool
|
||||||
|
|
||||||
|
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
|
||||||
|
// gives the recent-message count returned for a "scope|ref". lookupErr and
|
||||||
|
// countErr force the store errors that must fail open.
|
||||||
|
limits map[string]store.RateLimit
|
||||||
|
counts map[string]int64
|
||||||
|
lookupErr error
|
||||||
|
countErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
||||||
@@ -25,12 +37,32 @@ func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
|
||||||
|
f.rejected = append(f.rejected, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, error) {
|
||||||
|
if f.lookupErr != nil {
|
||||||
|
return store.RateLimit{}, false, f.lookupErr
|
||||||
|
}
|
||||||
|
rl, ok := f.limits[scope+"|"+ref]
|
||||||
|
return rl, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, error) {
|
||||||
|
if f.countErr != nil {
|
||||||
|
return 0, f.countErr
|
||||||
|
}
|
||||||
|
return f.counts[scope+"|"+ref], nil
|
||||||
|
}
|
||||||
|
|
||||||
func mods(kv map[string]string) *milter.Modifier {
|
func mods(kv map[string]string) *milter.Modifier {
|
||||||
return &milter.Modifier{Macros: kv}
|
return &milter.Modifier{Macros: kv}
|
||||||
}
|
}
|
||||||
|
|
||||||
// drive replays a typical message through one session and returns the recorder.
|
// drive replays a typical message through one session and returns the recorder.
|
||||||
func drive(t *testing.T, rec Recorder) *session {
|
func drive(t *testing.T, rec Store) *session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s := &session{rec: rec}
|
s := &session{rec: rec}
|
||||||
if _, err := s.Connect("localhost", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil {
|
if _, err := s.Connect("localhost", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil {
|
||||||
@@ -141,6 +173,135 @@ func TestBracedMacros(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
||||||
|
// register it so the differentiated check applies.
|
||||||
|
const limitIP = "203.0.113.7"
|
||||||
|
|
||||||
|
func activeLimit(ips ...string) store.RateLimit {
|
||||||
|
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
|
||||||
|
// which is where the level-2 limit is enforced.
|
||||||
|
func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
||||||
|
t.Helper()
|
||||||
|
s := &session{rec: rec}
|
||||||
|
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
|
||||||
|
t.Fatalf("Connect: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MailFrom: %v", err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("over-limit MAIL FROM = %v, want TempFail (4xx)", resp)
|
||||||
|
}
|
||||||
|
if len(rec.rejected) != 1 || rec.rejected[0].Domain != "example.com" {
|
||||||
|
t.Fatalf("want one rejected send-log row for example.com, got %+v", rec.rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
|
t.Fatalf("over app limit = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("under limit = %v, want Continue", resp)
|
||||||
|
}
|
||||||
|
if len(rec.rejected) != 0 {
|
||||||
|
t.Fatalf("under limit must not record a rejection: %+v", rec.rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
|
}
|
||||||
|
// The sender's IP is not in the domain's registered set, so level-2 does not
|
||||||
|
// apply even though the count is huge (level-1 anvil would still cover it).
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("inactive limit = %v, want Continue", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{lookupErr: errors.New("db down")}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("lookup error = %v, want Continue (fail-open)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||||
|
},
|
||||||
|
countErr: errors.New("db down"),
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("count error = %v, want Continue (fail-open)", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
limits: map[string]store.RateLimit{
|
||||||
|
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||||
|
},
|
||||||
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
|
}
|
||||||
|
// A session with no client IP (e.g. local submission) cannot be keyed.
|
||||||
|
s := &session{rec: rec}
|
||||||
|
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MailFrom: %v", err)
|
||||||
|
}
|
||||||
|
if resp != milter.RespContinue {
|
||||||
|
t.Fatalf("no-IP session = %v, want Continue", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDomainOf(t *testing.T) {
|
func TestDomainOf(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"user@Example.COM": "example.com",
|
"user@Example.COM": "example.com",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package milter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overLimit reports whether the message currently being received should be
|
||||||
|
// refused under a level-2 differentiated limit (spec 7.4). It checks the
|
||||||
|
// domain-level and application-level limits in turn; either being exceeded is
|
||||||
|
// enough to refuse.
|
||||||
|
//
|
||||||
|
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||||
|
// limit, is treated as "not over limit" so a malfunction of the level-2 limiter
|
||||||
|
// can never block mail — Postfix's level-1 anvil limit (spec 5) remains the
|
||||||
|
// backstop, and it does not depend on this milter at all. Only a clean count at
|
||||||
|
// or above a configured ceiling returns true.
|
||||||
|
func (s *session) overLimit() bool {
|
||||||
|
if s.clientIP == "" {
|
||||||
|
return false // no client IP to key on; level-2 does not apply
|
||||||
|
}
|
||||||
|
checks := []struct{ scope, ref string }{
|
||||||
|
{store.RateLimitScopeDomain, domainOf(s.from)},
|
||||||
|
{store.RateLimitScopeApp, s.login},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
if c.ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No limit configured, an inert draft, or a client IP outside the
|
||||||
|
// registered set: the differentiated limit does not apply here.
|
||||||
|
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||||
|
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n >= int64(rl.MaxMessages) {
|
||||||
|
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||||
|
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordRejected writes a send-log row for a message refused by a level-2 limit
|
||||||
|
// (spec 7.4, "опционально фиксирует ... для видимости в UI"), so the rejection
|
||||||
|
// shows up in the monitoring screen. Only MAIL-stage fields are known; the write
|
||||||
|
// is best-effort and never affects the response.
|
||||||
|
func (s *session) recordRejected() {
|
||||||
|
err := s.rec.InsertRejected(store.SendLogEntry{
|
||||||
|
Domain: domainOf(s.from),
|
||||||
|
AppLogin: s.login,
|
||||||
|
From: s.from,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("journal-milter: record rejected %s: %v", s.from, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
|
||||||
|
// (counted across all its applications and IPs) or to a single application.
|
||||||
|
const (
|
||||||
|
RateLimitScopeDomain = "domain"
|
||||||
|
RateLimitScopeApp = "application"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
|
||||||
|
// of expected client IPs plus a message ceiling over a sliding window, attached
|
||||||
|
// to a domain or an application. It is enforced in the journal-milter; level 1
|
||||||
|
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
|
||||||
|
// is absent or the milter is down.
|
||||||
|
//
|
||||||
|
// Both the IP binding and the ceiling are optional in the schema, but a limit is
|
||||||
|
// only enforced when it is Active(): the design deliberately allows an admin to
|
||||||
|
// leave the IP binding empty for apps that send from changing IPs, in which case
|
||||||
|
// only level 1 protects them (spec 7.4's caveat).
|
||||||
|
type RateLimit struct {
|
||||||
|
Scope string
|
||||||
|
RefID int64
|
||||||
|
AllowedIPs []string // canonical client IPs this limit applies to
|
||||||
|
MaxMessages int
|
||||||
|
WindowSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active reports whether the limit is fully configured and should be enforced.
|
||||||
|
// A missing IP binding, ceiling or window leaves the differentiated limit inert
|
||||||
|
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
|
||||||
|
func (r RateLimit) Active() bool {
|
||||||
|
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
||||||
|
// comparison parses both sides so equivalent textual forms of the same address
|
||||||
|
// match; a client IP outside the list means the differentiated limit does not
|
||||||
|
// apply to it (level 1 still does).
|
||||||
|
func (r RateLimit) AllowsIP(ip string) bool {
|
||||||
|
c := net.ParseIP(ip)
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range r.AllowedIPs {
|
||||||
|
if p := net.ParseIP(a); p != nil && p.Equal(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRateLimit loads the level-2 limit configured for a domain or application by
|
||||||
|
// its id, for the panel's edit form. ok is false when none is configured.
|
||||||
|
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT allowed_ips, max_messages, window_seconds
|
||||||
|
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
||||||
|
rl, err := scanRateLimit(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return RateLimit{Scope: scope, RefID: refID}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RateLimit{}, false, fmt.Errorf("get rate limit: %w", err)
|
||||||
|
}
|
||||||
|
rl.Scope, rl.RefID = scope, refID
|
||||||
|
return rl, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
|
||||||
|
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
|
||||||
|
// stored via bound parameters and read back live by the milter.
|
||||||
|
func (s *Store) SetRateLimit(rl RateLimit) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
||||||
|
allowed_ips = excluded.allowed_ips,
|
||||||
|
max_messages = excluded.max_messages,
|
||||||
|
window_seconds = excluded.window_seconds`,
|
||||||
|
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set rate limit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRateLimit removes the level-2 limit for a domain or application, so the
|
||||||
|
// admin can clear it and fall back to level 1 only.
|
||||||
|
func (s *Store) DeleteRateLimit(scope string, refID int64) error {
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID); err != nil {
|
||||||
|
return fmt.Errorf("delete rate limit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRateLimitsForDomain removes the domain's own limit and the limits of all
|
||||||
|
// its applications in one statement. It is called on domain deletion, before the
|
||||||
|
// application rows are cascade-deleted, to avoid leaving orphan limit rows.
|
||||||
|
func (s *Store) DeleteRateLimitsForDomain(domainID int64) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`DELETE FROM rate_limits
|
||||||
|
WHERE (scope = 'domain' AND ref_id = ?)
|
||||||
|
OR (scope = 'application' AND ref_id IN (SELECT id FROM applications WHERE domain_id = ?))`,
|
||||||
|
domainID, domainID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete domain rate limits: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimit resolves the level-2 limit that applies to a message, keyed by the
|
||||||
|
// human-readable reference the milter has on the receive path: the sending
|
||||||
|
// domain name (scope "domain") or the SASL login (scope "application"). ok is
|
||||||
|
// false when no limit is configured for that reference.
|
||||||
|
func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
||||||
|
var query string
|
||||||
|
switch scope {
|
||||||
|
case RateLimitScopeDomain:
|
||||||
|
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
|
||||||
|
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
|
||||||
|
WHERE rl.scope = 'domain' AND d.name = ?`
|
||||||
|
case RateLimitScopeApp:
|
||||||
|
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
|
||||||
|
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
||||||
|
WHERE rl.scope = 'application' AND a.login = ?`
|
||||||
|
default:
|
||||||
|
return RateLimit{}, false, fmt.Errorf("unknown rate-limit scope %q", scope)
|
||||||
|
}
|
||||||
|
rl, err := scanRateLimit(s.db.QueryRow(query, ref))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return RateLimit{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RateLimit{}, false, fmt.Errorf("rate limit for %s %q: %w", scope, ref, err)
|
||||||
|
}
|
||||||
|
rl.Scope = scope
|
||||||
|
return rl, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountMessages returns how many distinct messages the reference (a domain name
|
||||||
|
// or an application login) has queued since t, for the level-2 sliding window
|
||||||
|
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
|
||||||
|
// one message, matching level 1's per-message semantics — and excludes rows that
|
||||||
|
// were themselves rejected by a limit (they were never sent). It reuses the send
|
||||||
|
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
|
||||||
|
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
|
||||||
|
var column string
|
||||||
|
switch scope {
|
||||||
|
case RateLimitScopeDomain:
|
||||||
|
column = "domain"
|
||||||
|
case RateLimitScopeApp:
|
||||||
|
column = "app_login"
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unknown rate-limit scope %q", scope)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
|
||||||
|
// same format is chronologically correct (as in DeleteSendLogBefore).
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT COUNT(DISTINCT queue_id) FROM send_log
|
||||||
|
WHERE `+column+` = ? AND status != ? AND created_at >= ?`,
|
||||||
|
ref, StatusRejected, since.UTC().Format(time.RFC3339),
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count messages for %s %q: %w", scope, ref, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||||
|
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
||||||
|
// limit inert via Active().
|
||||||
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||||
|
var (
|
||||||
|
ips sql.NullString
|
||||||
|
maxMsgs sql.NullInt64
|
||||||
|
windowSecs sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := r.Scan(&ips, &maxMsgs, &windowSecs); err != nil {
|
||||||
|
return RateLimit{}, err
|
||||||
|
}
|
||||||
|
return RateLimit{
|
||||||
|
AllowedIPs: splitIPs(ips.String),
|
||||||
|
MaxMessages: int(maxMsgs.Int64),
|
||||||
|
WindowSeconds: int(windowSecs.Int64),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitIPs parses the comma-separated storage form back into a slice, dropping
|
||||||
|
// empties so an empty column yields nil (an inactive limit).
|
||||||
|
func splitIPs(s string) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRateLimitSetGetDelete(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d, err := st.AddDomain("example.com", "selfpost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddDomain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID); err != nil || ok {
|
||||||
|
t.Fatalf("GetRateLimit on empty: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := RateLimit{
|
||||||
|
Scope: RateLimitScopeDomain,
|
||||||
|
RefID: d.ID,
|
||||||
|
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
|
||||||
|
MaxMessages: 100,
|
||||||
|
WindowSeconds: 3600,
|
||||||
|
}
|
||||||
|
if err := st.SetRateLimit(want); err != nil {
|
||||||
|
t.Fatalf("SetRateLimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
|
||||||
|
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
|
||||||
|
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert replaces in place (UNIQUE(scope, ref_id)).
|
||||||
|
want.MaxMessages = 5
|
||||||
|
want.AllowedIPs = []string{"198.51.100.9"}
|
||||||
|
if err := st.SetRateLimit(want); err != nil {
|
||||||
|
t.Fatalf("SetRateLimit upsert: %v", err)
|
||||||
|
}
|
||||||
|
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
||||||
|
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" {
|
||||||
|
t.Fatalf("upsert did not replace: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.DeleteRateLimit(RateLimitScopeDomain, d.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteRateLimit: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
|
||||||
|
t.Fatalf("limit still present after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitByNameAndLogin(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d, _ := st.AddDomain("example.com", "selfpost")
|
||||||
|
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddApplication: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil {
|
||||||
|
t.Fatalf("set domain limit: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil {
|
||||||
|
t.Fatalf("set app limit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The milter resolves limits by domain name and by SASL login.
|
||||||
|
rl, ok, err := st.RateLimit(RateLimitScopeDomain, "example.com")
|
||||||
|
if err != nil || !ok || rl.MaxMessages != 10 {
|
||||||
|
t.Fatalf("RateLimit domain: ok=%v err=%v rl=%+v", ok, err, rl)
|
||||||
|
}
|
||||||
|
rl, ok, err = st.RateLimit(RateLimitScopeApp, "app1")
|
||||||
|
if err != nil || !ok || rl.MaxMessages != 3 {
|
||||||
|
t.Fatalf("RateLimit app: ok=%v err=%v rl=%+v", ok, err, rl)
|
||||||
|
}
|
||||||
|
if _, ok, _ := st.RateLimit(RateLimitScopeDomain, "unknown.example"); ok {
|
||||||
|
t.Fatalf("RateLimit for unknown domain should be not-ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountMessagesDistinctAndWindowed(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
// Two recipients share a queue-id → one message. A second message → two.
|
||||||
|
for _, to := range []string{"a@x.net", "b@x.net"} {
|
||||||
|
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", Domain: "example.com", AppLogin: "app1", To: to}); err != nil {
|
||||||
|
t.Fatalf("InsertQueued: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := st.InsertQueued(SendLogEntry{QueueID: "Q2", Domain: "example.com", AppLogin: "app1", To: "c@x.net"}); err != nil {
|
||||||
|
t.Fatalf("InsertQueued: %v", err)
|
||||||
|
}
|
||||||
|
// A rejected row must not count toward the window.
|
||||||
|
if err := st.InsertRejected(SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "s@example.com"}); err != nil {
|
||||||
|
t.Fatalf("InsertRejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountMessages: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("distinct-message count = %d, want 2 (two queue-ids, rejected excluded)", n)
|
||||||
|
}
|
||||||
|
n, _ = st.CountMessages(RateLimitScopeApp, "app1", time.Now().Add(-time.Hour))
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("app count = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdate Q1 beyond the window: only Q2 remains inside a 30-minute window.
|
||||||
|
old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
|
||||||
|
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'Q1'`, old); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
n, _ = st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-30*time.Minute))
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("windowed count = %d, want 1 (Q1 aged out)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d, _ := st.AddDomain("example.com", "selfpost")
|
||||||
|
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||||
|
other, _ := st.AddDomain("other.example", "selfpost")
|
||||||
|
|
||||||
|
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60})
|
||||||
|
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60})
|
||||||
|
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60})
|
||||||
|
|
||||||
|
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
|
||||||
|
t.Fatalf("domain limit survived")
|
||||||
|
}
|
||||||
|
if _, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID); ok {
|
||||||
|
t.Fatalf("application limit survived")
|
||||||
|
}
|
||||||
|
// The unrelated domain's limit is untouched.
|
||||||
|
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, other.ID); !ok {
|
||||||
|
t.Fatalf("unrelated domain limit was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||||
|
inactive := []RateLimit{
|
||||||
|
{},
|
||||||
|
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||||
|
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||||
|
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
||||||
|
}
|
||||||
|
for i, rl := range inactive {
|
||||||
|
if rl.Active() {
|
||||||
|
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
||||||
|
if !active.Active() {
|
||||||
|
t.Fatalf("should be active: %+v", active)
|
||||||
|
}
|
||||||
|
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
||||||
|
t.Fatalf("registered IPs should match")
|
||||||
|
}
|
||||||
|
// Equivalent textual form of the IPv6 address must still match.
|
||||||
|
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||||
|
t.Fatalf("expanded IPv6 form should match")
|
||||||
|
}
|
||||||
|
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
||||||
|
t.Fatalf("unregistered/invalid IPs must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const (
|
|||||||
StatusSent = "sent"
|
StatusSent = "sent"
|
||||||
StatusDeferred = "deferred"
|
StatusDeferred = "deferred"
|
||||||
StatusBounced = "bounced"
|
StatusBounced = "bounced"
|
||||||
|
// StatusRejected marks a message the journal-milter refused with a 4xx under
|
||||||
|
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
|
||||||
|
// excluded from the level-2 message count (it was never sent).
|
||||||
|
StatusRejected = "rejected"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SendLogEntry is a single queued send-log row. The journal-milter creates one
|
// SendLogEntry is a single queued send-log row. The journal-milter creates one
|
||||||
@@ -46,6 +50,24 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsertRejected records a message the journal-milter refused under a level-2
|
||||||
|
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
|
||||||
|
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
|
||||||
|
// queue-id or recipient because the message was rejected before it was queued.
|
||||||
|
func (s *Store) InsertRejected(e SendLogEntry) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO send_log
|
||||||
|
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert rejected send_log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateStatus advances the delivery status of the send-log rows matching a
|
// UpdateStatus advances the delivery status of the send-log rows matching a
|
||||||
// (queue-id, recipient) pair, which the log-tailer parses out of mail.log.
|
// (queue-id, recipient) pair, which the log-tailer parses out of mail.log.
|
||||||
// Recipient matching is case-insensitive because Postfix may normalise address
|
// Recipient matching is case-insensitive because Postfix may normalise address
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ type detailView struct {
|
|||||||
FormMode string
|
FormMode string
|
||||||
FormAddrs string
|
FormAddrs string
|
||||||
NewCred *newCred
|
NewCred *newCred
|
||||||
|
// RateLimitErr surfaces a validation error from a domain- or application-level
|
||||||
|
// rate-limit form (spec 7.4) as a page banner.
|
||||||
|
RateLimitErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// appRateLimitView pairs an application with its differentiated rate-limit
|
||||||
|
// settings for the domain page. store.Application is embedded so the existing
|
||||||
|
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
|
||||||
|
type appRateLimitView struct {
|
||||||
|
store.Application
|
||||||
|
HasLimit bool // an active limit is configured
|
||||||
|
IPsText string // allowed IPs, newline-joined for the textarea
|
||||||
|
MaxText string // message ceiling, blank when unset
|
||||||
|
WindowVal string // window seconds, defaulted when unset
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
|
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
|
||||||
@@ -56,12 +70,36 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
appViews := make([]appRateLimitView, 0, len(apps))
|
||||||
|
for _, a := range apps {
|
||||||
|
rl, ok, err := s.apps.RateLimit(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: application %d: rate limit: %v", a.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appViews = append(appViews, appRateLimitView{
|
||||||
|
Application: a,
|
||||||
|
HasLimit: ok && rl.Active(),
|
||||||
|
IPsText: strings.Join(rl.AllowedIPs, "\n"),
|
||||||
|
MaxText: intOrBlank(rl.MaxMessages),
|
||||||
|
WindowVal: windowOrDefault(rl.WindowSeconds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.render(w, status, "domain_detail", map[string]any{
|
s.render(w, status, "domain_detail", map[string]any{
|
||||||
"Title": "SelfPost — " + d.Name,
|
"Title": "SelfPost — " + d.Name,
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Domain": d,
|
"Domain": d,
|
||||||
"Record": record,
|
"Record": record,
|
||||||
"Apps": apps,
|
"Apps": appViews,
|
||||||
"Error": view.FormErr,
|
"Error": view.FormErr,
|
||||||
"FormLogin": view.FormLogin,
|
"FormLogin": view.FormLogin,
|
||||||
"FormMode": view.FormMode,
|
"FormMode": view.FormMode,
|
||||||
@@ -70,9 +108,32 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
"Flash": detailFlash(r),
|
"Flash": detailFlash(r),
|
||||||
"Wildcard": store.AddressModeWildcard,
|
"Wildcard": store.AddressModeWildcard,
|
||||||
"List": store.AddressModeList,
|
"List": store.AddressModeList,
|
||||||
|
"RateLimitErr": view.RateLimitErr,
|
||||||
|
"DomainHasRL": domainRLok && domainRL.Active(),
|
||||||
|
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
||||||
|
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
||||||
|
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intOrBlank renders a non-positive number as an empty string so an unset field
|
||||||
|
// shows blank rather than "0".
|
||||||
|
func intOrBlank(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowOrDefault renders the window seconds, substituting the default when
|
||||||
|
// unset so the form always suggests a sensible value.
|
||||||
|
func windowOrDefault(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return strconv.Itoa(defaultRateLimitWindowSeconds)
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
||||||
// after a redirect is never attacker-influenced.
|
// after a redirect is never attacker-influenced.
|
||||||
func detailFlash(r *http.Request) string {
|
func detailFlash(r *http.Request) string {
|
||||||
@@ -81,6 +142,8 @@ func detailFlash(r *http.Request) string {
|
|||||||
return "Application deleted."
|
return "Application deleted."
|
||||||
case r.URL.Query().Get("modeupdated") != "":
|
case r.URL.Query().Get("modeupdated") != "":
|
||||||
return "Application address mode updated."
|
return "Application address mode updated."
|
||||||
|
case r.URL.Query().Get("ratelimit") != "":
|
||||||
|
return "Rate limit updated."
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultRateLimitWindowSeconds is the sliding-window length used when an admin
|
||||||
|
// sets a message ceiling but leaves the window blank (spec 7.4, matching the
|
||||||
|
// level-1 default hour, spec 8: RATE_LIMIT_WINDOW_SECONDS).
|
||||||
|
const defaultRateLimitWindowSeconds = 3600
|
||||||
|
|
||||||
|
// rateLimitInput is the validated result of a rate-limit form submission. clear
|
||||||
|
// means "remove the differentiated limit" (spec 7.4: an empty IP binding leaves
|
||||||
|
// only level 1).
|
||||||
|
type rateLimitInput struct {
|
||||||
|
clear bool
|
||||||
|
ips []string
|
||||||
|
maxMessages int
|
||||||
|
windowSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRateLimitForm validates a rate-limit submission on the server (spec
|
||||||
|
// 7.6.2). It returns clear=true when the admin removes the limit or leaves the
|
||||||
|
// IP binding empty; otherwise it requires a positive ceiling and window. The
|
||||||
|
// returned error's message is safe to show to the admin.
|
||||||
|
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
|
}
|
||||||
|
if r.PostFormValue("clear") != "" {
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||||
|
if err != nil {
|
||||||
|
return rateLimitInput{}, err
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
// No IP binding: the differentiated limit does not apply (spec 7.4).
|
||||||
|
return rateLimitInput{clear: true}, nil
|
||||||
|
}
|
||||||
|
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
||||||
|
if err != nil || maxMessages <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||||
|
}
|
||||||
|
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||||
|
if err != nil || windowSeconds <= 0 {
|
||||||
|
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||||
|
}
|
||||||
|
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
|
||||||
|
// whitespace) into a deduplicated list of canonical addresses, rejecting any
|
||||||
|
// token that is not a valid IP (spec 7.6.2). The values are only ever stored as
|
||||||
|
// SQLite parameters and compared in the milter, never written to a config file.
|
||||||
|
func parseIPList(raw string) ([]string, error) {
|
||||||
|
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||||||
|
})
|
||||||
|
var out []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, f := range fields {
|
||||||
|
ip := net.ParseIP(f)
|
||||||
|
if ip == nil {
|
||||||
|
return nil, fmt.Errorf("%q is not a valid IP address", f)
|
||||||
|
}
|
||||||
|
c := ip.String()
|
||||||
|
if !seen[c] {
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePositiveInt parses a trimmed integer field, returning def when it is
|
||||||
|
// blank. A non-numeric value returns an error.
|
||||||
|
func parsePositiveInt(raw string, def int) (int, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return strconv.Atoi(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDomainRateLimit saves or clears a domain-level differentiated rate limit
|
||||||
|
// (spec 7.4). No reload is needed — the milter reads the row live.
|
||||||
|
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d, ok := s.lookupDomain(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := parseRateLimitForm(r)
|
||||||
|
if err != nil {
|
||||||
|
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
|
FormMode: store.AddressModeWildcard,
|
||||||
|
RateLimitErr: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
|
||||||
|
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAppRateLimit saves or clears an application-level differentiated rate
|
||||||
|
// limit (spec 7.4).
|
||||||
|
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := s.lookupApplication(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.domains.Get(a.DomainID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := parseRateLimitForm(r)
|
||||||
|
if err != nil {
|
||||||
|
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
|
FormMode: store.AddressModeWildcard,
|
||||||
|
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
|
||||||
|
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRateLimit dispatches a validated input to the save or clear method of the
|
||||||
|
// relevant service, keyed by the domain or application id.
|
||||||
|
func (s *Server) applyRateLimit(
|
||||||
|
in rateLimitInput,
|
||||||
|
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
||||||
|
clear func(id int64) error,
|
||||||
|
id int64,
|
||||||
|
) error {
|
||||||
|
if in.clear {
|
||||||
|
return clear(id)
|
||||||
|
}
|
||||||
|
return save(id, in.ips, in.maxMessages, in.windowSeconds)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<a class="back" href="/">← All domains</a>
|
<a class="back" href="/">← All domains</a>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||||
|
|
||||||
{{if .NewCred}}
|
{{if .NewCred}}
|
||||||
<div class="card credential">
|
<div class="card credential">
|
||||||
@@ -79,6 +80,25 @@
|
|||||||
<button type="submit">Save mode</button>
|
<button type="submit">Save mode</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
|
||||||
|
<form method="post" action="/applications/{{.ID}}/ratelimit">
|
||||||
|
<label>Expected client IPs (one per line or comma-separated)</label>
|
||||||
|
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||||
|
<button type="submit">Save limit</button>
|
||||||
|
</form>
|
||||||
|
{{if .HasLimit}}
|
||||||
|
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
||||||
|
onsubmit="return confirm('Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.')">
|
||||||
|
<input type="hidden" name="clear" value="1">
|
||||||
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</details>
|
||||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||||
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
||||||
<button type="submit">New password</button>
|
<button type="submit">New password</button>
|
||||||
@@ -97,6 +117,41 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sending rate limit (domain)</h2>
|
||||||
|
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
|
||||||
|
domain may send from its expected client IP(s) within a time window, summed
|
||||||
|
across all its applications. It counts messages — one message to many
|
||||||
|
recipients counts once. Leave the IP list empty to disable it and rely only on
|
||||||
|
the global level-1 limit. Applications that send from changing IPs should be
|
||||||
|
left unbound here.</p>
|
||||||
|
|
||||||
|
<p class="muted">Status:
|
||||||
|
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||||
|
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
||||||
|
<textarea id="d_ips" name="allowed_ips" rows="2"
|
||||||
|
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
||||||
|
|
||||||
|
<label for="d_max">Message limit</label>
|
||||||
|
<input id="d_max" name="max_messages" type="number" min="1"
|
||||||
|
value="{{.DomainRLMax}}" placeholder="1000">
|
||||||
|
|
||||||
|
<label for="d_win">Window (seconds)</label>
|
||||||
|
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||||
|
|
||||||
|
<button type="submit">Save limit</button>
|
||||||
|
</form>
|
||||||
|
{{if .DomainHasRL}}
|
||||||
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
||||||
|
onsubmit="return confirm('Remove the domain rate limit? Only the global level-1 limit will apply.')">
|
||||||
|
<input type="hidden" name="clear" value="1">
|
||||||
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Add an application</h2>
|
<h2>Add an application</h2>
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
||||||
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
||||||
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
||||||
|
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
|
||||||
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
||||||
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
||||||
|
authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit)
|
||||||
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
||||||
authed.HandleFunc("POST /reload", s.handleReload)
|
authed.HandleFunc("POST /reload", s.handleReload)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user