Практический разбор тюнинга почтового сервера: 18 шагов, реальные команды, найденные грабли и одна нерешённая загадка
Введение

У меня развёрнут Stalwart Mail Server 1.1.0 (Community) на AlmaLinux 10, перед ним HAProxy в режиме TCP passthrough с PROXY protocol, на периметре — MikroTik с RouterOS 7. Почта ходит, письма доставляются, Mail-Tester даёт 10/10, SPF/DKIM/DMARC проходят проверки.
Казалось бы — всё хорошо, можно закрывать терминал.
Но когда я начал планомерно выгружать конфигурацию объект за объектом и читать, что там реально настроено, обнаружилось следующее:
- у исходящей почты вообще не было лимитов — ни одного правила троттлинга;
- DANE был включён в настройках, но фактически не работал — сервер молча его отключил;
- порог защиты от брутфорса составлял 100 попыток за 24 часа;
- таймаут TLS-рукопожатия при исходящей доставке — 3 минуты;
- HSTS был выключен, защитных заголовков не было вовсе;
- MTA-STS висел в режиме
testing, то есть ничего не защищал.
Ни одна из этих проблем не мешала почте ходить, но все они стали бы очень заметны в момент инцидента.
Эта статья — разбор того, что я поменял, почему именно так, и на какие грабли по дороге наступил. Отдельно расскажу про загадку с DKIM Ed25519 у Gmail, которую я так и не решил до конца — но зато честно проверил три гипотезы и две из них отбросил.
Дисклеймер. Все числовые значения подобраны под мой профиль нагрузки (небольшой сервер, десятки ящиков, WordPress как один из источников исходящей почты). Не копируйте их слепо — ниже я объясняю логику выбора, чтобы вы могли посчитать свои. Изменения применяйте сначала на тестовом стенде.
1. Сначала правила игры с stalwart-cli
Прежде чем что-то менять, нужно понять синтаксис CLI. Я потратил на это больше времени, чем на сам тюнинг, потому что документация местами расходится с реалиями версии 1.1.0.
Правила, которые сэкономят вам вечер:
| Правило | Пояснение |
|---|---|
update <Object> <ID> | ID обязателен. Для синглтонов это литерал singleton |
get <Object> <ID> | Запрос по ID, не по имени. get MtaTlsStrategy default не сработает |
Флага --where нет | Никаких фильтров в update. Только точный ID |
describe <Object> — перед любым изменением | Единственный достоверный источник имён полей и типов, не документация |
object <Expression> | Требует JSON: {"match":{},"else":"значение"} |
set <...> | Кодируется map-объектом: {"элемент":true}, не массивом |
number <duration> | Число в миллисекундах. Выражения не поддерживает |
PowerShell | JSON оборачивать в одинарные кавычки |
| Сложный контент | Только через файл: --json (Get-Content .\file.json -Raw) |
И главное правило эксплуатации:
stalwart-cli create Action/ReloadSettings --json "{}"
После каждого блока изменений. Без этого правки лежат в базе, но не в памяти работающего процесса. Я несколько раз ловил себя на том, что «настройка не применилась» — она применилась, просто не была перечитана.
Ещё нюанс Community-редакции: query Trace требует Enterprise-лицензии. Для диагностики используем query Log и query QueuedMessage.
2. Диагностика: с чего начинать
Три источника информации, в порядке полезности:
Заголовки реального письма. Отправляете себе на Gmail/Mail.ru, открываете «Показать оригинал», читаете Authentication-Results. Там видно всё: SPF, DKIM по каждому селектору, DMARC с политикой, версию TLS, rDNS.
Mail-Tester. Даёт внешнюю оценку и, что важнее, проверяет IP по 20 блэклистам. Мой результат — 10/10 при SpamAssassin score 0.2.
Логи Stalwart. Ищем не ошибки, а аномалии:
stalwart-cli query Log --json | Select-String "delivery.delivered|delivery.connect-error"
Ключевой параметр — elapsed. Нормальная исходящая доставка занимает 700–3000 мс. Всё, что выше 30 секунд, требует разбирательства.
И отдельно — предупреждения сборки конфигурации. Именно там я нашёл самое интересное:
WARN registry.build-warning objectType="DnsResolver"
reason = "The configured DNS resolver cannot validate DNSSEC.
DANE has been disabled to avoid deferring mail."
Об этом — в разделе 7.
3. NetworkListener: лимиты на соединения
Что было: поля tlsTimeout, socketBacklog, maxConnections отсутствовали. Бот мог открыть соединение, не завершить TLS-рукопожатие и держать сокет неограниченно долго. Классический Slowloris.
Сначала получаем инвентаризацию ID:
stalwart-cli query NetworkListener --json
| Listener | ID |
|---|---|
| smtp | jnue90gaaeqb |
| submissions | jnue90gaafab |
| imaps | jnue90gaafqb |
| pop3s | jnue90gaagab |
| sieve | jnue90gaagqb |
| http | jnue90gaahqb |
| https | jnue90gaahab |
Применяем:
stalwart-cli update NetworkListener jnue90gaaeqb --field tlsTimeout=15000 --field socketBacklog=2048 --field maxConnections=2000
stalwart-cli update NetworkListener jnue90gaafab --field tlsTimeout=15000 --field socketBacklog=1024 --field maxConnections=1000
stalwart-cli update NetworkListener jnue90gaafqb --field tlsTimeout=30000 --field socketBacklog=1024 --field maxConnections=1000
stalwart-cli create Action/ReloadSettings --json "{}"
Логика значений. 15 секунд на TLS для SMTP — с запасом: нормальный клиент укладывается в единицы секунд даже на плохом мобильном интернете. Для IMAP поставил 30 секунд, потому что клиенты иногда стартуют медленно. maxConnections считается от ожидаемого пика легитимных клиентов с кратным запасом — 2000 для публичного SMTP у меня с головой.
4. SMTP-пайплайн: точечные правки
4.1 Что я НЕ стал трогать
Прежде чем менять — читайте. Часть настроек у меня уже была образцовой:
MtaStageEhlo: rejectNonFqdn = true для порта 25 и false для submission. Публичный SMTP требует корректный FQDN, локальные клиенты не ломаются. Идеально.
MtaStageRcpt: waitOnFail = 5s — золотой стандарт против Dictionary Attack. Бот, перебирающий адреса, ждёт 5 секунд на каждой ошибке. Тысяча адресов = полтора часа вместо секунд. Плюс maxFailures = 5 — разрыв сессии после пяти промахов.
MtaStageMail: isSenderAllowed = !is_empty(authenticated_as) || !key_exists('spam-block', sender_domain) — корректная защита от подделки локальных доменов.
Не нужно трогать то, что работает правильно.
4.2 MtaStageData: размер письма
Было 100 МБ. Стандарт индустрии — 25–50 МБ, и большинство крупных провайдеров всё равно не примут больше 25–35 МБ. Держать 100 МБ на входящих — значит разрешить кому-то заливать вам сто мегабайт за сессию.
stalwart-cli update MtaStageData singleton --field maxMessageSize='{"match":{},"else":"52428800"}'
stalwart-cli create Action/ReloadSettings --json "{}"
4.3 SenderAuth: включаем ARC
Было arcVerify = disable. ARC (Authenticated Received Chain) — механизм, который сохраняет результаты проверок при пересылке письма через форвардеры и списки рассылки. Без него письмо, прошедшее через mailing list с переписанным From, теряет DKIM-валидность и получает штраф на ровном месте.
stalwart-cli update SenderAuth singleton --field arcVerify='{"match":{},"else":"relaxed"}'
stalwart-cli create Action/ReloadSettings --json "{}"
Обратите внимание на синтаксис: object<Expression> требует полную JSON-структуру с match и else.
5. Security: Auto-Ban и неочевидная арифметика порогов
Auto-Ban — механизм, который автоматически блокирует IP за повторяющиеся нарушения. В Stalwart он разделён на четыре независимых правила, и настраивать их надо понимая, как именно считается порог. Здесь легко получить конфигурацию, которая выглядит строже исходной, а на деле слабее.
5.1 Исходное состояние
stalwart-cli get Security singleton --json
| Параметр | Механизм | Что считает | Порог | Длительность бана |
|---|---|---|---|---|
authBanRate | auth | неудачные логины | 100 попыток / 24 часа | null |
abuseBanRate | abuse | relay + failed RCPT TO | 35 / 24 часа | null |
loiterBanRate | loiter | висящие соединения | 150 / 24 ч | null |
scanBanRate | scan | обращения к путям-ловушкам | 30 / 24 часа | null |
Все четыре BanPeriod равны null. Пороги считаются, наказания нет.
5.2 Rate и Period — два независимых параметра
Схема разделяет «сколько событий до срабатывания» и «на сколько блокируем»:
stalwart-cli describe Security
authBanRate object<Rate>? The maximum number of failed login
attempts before the IP is banned
authBanPeriod number<duration>? The duration of the ban for failed login attempts
Rate — это {count, period}, окно наблюдения. Period в BanPeriod — длительность блокировки. Первое без второго — счётчик в пустоту.
Семантику null схема не описывает, но WebUI показывает её наглядно: в блоке Loitering ban поле Duration отображает плейсхолдер «Not set», тогда как у заполненных полей стоят значения с единицами измерения. То есть null = поле не задано, и заполнять его надо.

Что именно делает сервер при незаданной длительности — не банит вовсе или применяет внутренний дефолт — я не проверял. Для практики это неважно: поле пустое, его нужно заполнить.
5.3 Ловушка: уменьшение окна не всегда ужесточение
Интуитивно кажется, что переход от «100 попыток за сутки» к «10 попыткам за час» — очевидное ужесточение. Окно меньше, порог ниже, реакция быстрее.
Но есть нюанс. В сутках 24 часов, и 10 × 24 = 240. Формально новый порог разрешает больше событий в сутки, чем старый. Разберём, когда это важно, а когда нет.
Считать надо «попыток до блокировки»
Ключевой момент: атакующий не продолжает работу после срабатывания порога. Забаненный IP не может даже установить соединение — оно отбрасывается до SMTP-диалога. Бан обрывает последовательность, а не просто считает её.
Поэтому корректная метрика — не «сколько попыток разрешено в окне», а «сколько атакующий успеет сделать до блокировки».
Сценарий А: быстрый брутфорс
Атакующий долбит словарь с одного IP, 50 попыток в минуту.
100/24ч, бан null | 10/1ч, бан 24ч | |
|---|---|---|
| Попыток до срабатывания | 100 | 10 |
| Время до реакции | ~2 минуты | ~12 секунд |
| Что дальше | блокировка неопределённой длительности | IP отрезан на сутки |
| Итого попыток за 24 ч | 100+ | 10 |
Попытки с 11-й по 240-ю физически не происходят. Здесь короткое окно выигрывает на порядок — и это подавляющее большинство реальных атак.
Сценарий Б: медленный подбор ниже порога
А вот здесь арифметика работает против нас.
Атакующий делает 9 попыток в час. Порог 10 не достигается, бан не срабатывает никогда:
9 попыток/час × 24 часа = 216 попыток/сутки
Правило 100/24ч поймало бы его на 100-й попытке — примерно на 11-м часу. Правило 10/1ч не поймает вообще.
Против медленного подбора короткое окно с прежним по величине порогом слабее длинного.
Формула выбора порога
Выведем условие, при котором порог N попыток в час не хуже правила 100/24ч по медленному вектору.
Атакующий, остающийся ниже порога, делает максимум (N − 1) попыток в час. За сутки:
(N − 1) × 24
Хотим, чтобы это было меньше 100:
(N − 1) × 24 < 100 N − 1 < 4.166… N ≤ 5
Только порог 5 попыток в час оказывается строже исходного правила по обоим сценариям.
| Порог | Быстрый брутфорс: попыток до бана | Медленный подбор: попыток/сутки без бана | Строже 100/24ч? |
|---|---|---|---|
| 100 / 24 ч (исходный) | 100 | 100 | — |
| 20 / час | 20 | 456 | ❌ по медленному хуже |
| 10 / час | 10 | 216 | ❌ по медленному хуже |
| 6 / час | 6 | 120 | ❌ по медленному хуже |
| 5 / час | 5 | 96 | ✅ по обоим лучше |
| 3 / час | 3 | 48 | ✅ по обоим лучше |
Вывод: уменьшать окно нужно вместе с порогом. Иначе суточная квота событий растёт.
5.4 Почему два правила не сделать
Естественное желание — настроить два окна: короткое для всплесков и длинное для медленных атак. Не получится:
authBanRate object<Rate>?
Это скаляр, а не массив. Одно правило на механизм. Приходится выбирать приоритет.
И здесь стоит оценить, насколько вероятен медленный подбор с одного IP. На практике он экзотичен: реальные ботнеты работают либо быстро с одного адреса (сценарий А), либо распределённо с тысяч адресов, где каждый делает 2–3 попытки и никогда не достигнет порога.
Против распределённой атаки authBanRate не помогает вообще — никакой порог по IP не сработает. Там нужны другие механизмы:
- длинные пароли и app passwords (у служебных аккаунтов 32 символа, человек его не вводит);
- 2FA для человеческих аккаунтов;
rateLimitAnonymousв HTTP (100 req/min);MtaInboundThrottleпоremoteIp(5/сек).
5.5 Цена ужесточения: ложные срабатывания
Порог 5/час математически оптимален, но у него есть цена.
Человек с телефоном, ноутбуком и планшетом, где пароль сменился, легко наберёт 5 неудач за час на фоновых переподключениях IMAP. И получит сутки бана без объяснений.
Решение зависит от количества аккаунтов и чаще всего истина будет где-то посередине.
stalwart-cli update Security singleton --field authBanRate='{"count":5,"period":3600000}' --field authBanPeriod=86400000
stalwart-cli create Action/ReloadSettings --json "{}"
stalwart-cli create Action/ReloadBlockedIps --json "{}"
Это закрывает оба вектора: быстрый брутфорс отрезается после 5 попыток, медленный подбор ограничен 96 попытками в сутки.
Если у вас десятки (сотни, тысячи) пользователей с несколькими устройствами — начните с 10–15/час, посмотрите статистику банов неделю через WebUI (Security → Blocked IPs) и снижайте, только если ложных срабатываний нет.
5.6 Loitering: бан мягче, чем для auth
Loitering — соединения, которые открываются и висят без активности до таймаута. 150 таких за сутки без наказания — разрешение держать полуоткрытые сокеты сутками.
Закрываем, но с меньшей длительностью:
stalwart-cli update Security singleton --field loiterBanPeriod=3600000
stalwart-cli create Action/ReloadSettings --json "{}"
stalwart-cli create Action/ReloadBlockedIps --json "{}"
Час, а не сутки. Легитимные мобильные клиенты на плохой сети рвут соединения постоянно — суточный бан за это был бы перебором. Час отсекает злостных, но не наказывает человека в транспорте.
5.7 Abuse: взаимодействие с maxFailures
Схема уточняет, что считается abuse:
«The maximum number of abuse attempts (relaying or failed RCPT TO attempts) before the IP is banned»
Теперь сложим это с настройкой MtaStageRcpt, где стоит maxFailures = 5 — разрыв сессии после пяти неверных получателей:
3 сессии × 5 неверных адресов = 15 → бан на 24 часа
Сценарий: отправляете письмо на корпоративный список из 20 адресов, половина сотрудников уволилась. Два-три захода — и собственный IP в бане на сутки. Причём это ваш же исходящий relay.
stalwart-cli update Security singleton --field abuseBanRate='{"count":15,"period":3600000}' --field abuseBanPeriod=86400000
stalwart-cli create Action/ReloadSettings --json "{}"
Для профиля «WordPress шлёт на один-два адреса» риск нулевой. Если появятся рассылки по спискам — вариант один из трёх:
- поднять
abuseBanRateдо 30–50/час (ослабит защиту от dictionary attack, ноwaitOnFail = 5sвMtaStageRcptостаётся основным барьером); - чистить списки перед отправкой;
- разносить рассылку по времени, не превышая порог.
5.8 Scan: два механизма, а не один
Порог 30 попыток сканирования в сутки выглядит слабо. Но здесь работают два независимых механизма.
Смотрим на scanBanPaths:
{"*.php*":true, "*.cgi*":true, "*.asp*":true, "*/wp-*":true, "*/php*":true,
"*/cgi-bin*":true, "*xmlrpc*":true, "*../*":true, "*/..*":true,
"*joomla*":true, "*wordpress*":true, "*drupal*":true}
И на описание в схеме:
«The paths that will trigger an immediate ban if accessed. Each path should be a glob expression»
Механизм 1 — мгновенный. Обращение к любому пути из списка → немедленный бан на scanBanPeriod, без счётчика. Один запрос /wp-login.php — и адрес отрезан на неделю.
Механизм 2 — эвристика. scanBanRate 30/сутки ловит сканирование, не попавшее в glob-ловушки.
Первый — основной, и он работает жёстко. Поэтому 30/сутки во втором правиле не беспокоит: до него дело обычно не доходит.
stalwart-cli update Security singleton --field scanBanPeriod=604800000
stalwart-cli create Action/ReloadSettings --json "{}"
5.9 Что осталось непроверенным: тип окна
Схема описывает Rate как {count, period}, но не указывает, скользящее окно или фиксированное. А это влияет на расчёты.
Если окно фиксированное и обнуляется на границе интервала, атакующий может сделать (N−1) попыток в конце часа и (N−1) сразу после границы:
2 × (N − 1) попыток за минуту, без бана
При пороге 5/час это 8 попыток за минуту на стыке часов. Дополнительный вектор, который надо знать.
Как проверить (я не проверял)
Временно уменьшаем окно, чтобы не ждать час между прогонами:
stalwart-cli update Security singleton --field authBanRate='{"count":4,"period":600000}'
stalwart-cli create Action/ReloadSettings --json "{}"
С тестового IP делаем 3 неудачные SMTP AUTH в конце десятиминутного интервала, затем 3 сразу после границы.
| Наблюдение | Вывод |
|---|---|
| Бан сработал | окно скользящее, 6 попыток попали в один 10-минутный отрезок |
| Бан не сработал | окно фиксированное, счётчик обнулился на границе |
Скрипт для прогона:
#!/usr/bin/env bash
# ЗАПУСКАТЬ С ТЕСТОВОГО IP, НЕ С ПОЧТОВОГО СЕРВЕРА
set -uo pipefail
SERVER="${SERVER:-mail.grib69.ru}"
PORT="${PORT:-465}"
USER="${USER:-ban-probe@grib69.ru}" # несуществующий логин
PASS="DefinitelyWrongPassword123"
COUNT="${COUNT:-12}"
echo ">>> Внешний IP этого хоста (его ищи в Blocked IPs):"
curl -fsS --max-time 10 https://ifconfig.me; echo
for i in $(seq 1 "$COUNT"); do
printf "[%02d/%02d] " "$i" "$COUNT"
OUT=$(curl -sS -v --max-time 20 \
--url "smtp://${SERVER}:${PORT}" \
--ssl --insecure \
--mail-from "$USER" --mail-rcpt "postmaster@${SERVER}" \
--user "${USER}:${PASS}" \
--upload-file /dev/null 2>&1)
if echo "$OUT" | grep -qiE 'Authentication failed|535|534|530'; then
echo "AUTH отклонён (ожидаемо)"
elif echo "$OUT" | grep -qiE 'refused|timed out|Empty reply|Could not connect|reset by peer'; then
echo; echo "*** СОЕДИНЕНИЕ ОБОРВАНО — вероятно, БАН на попытке $i ***"
break
else
echo "иной ответ"; echo "$OUT" | grep -E '^[<>*]' | tail -3
fi
sleep 2
done
Три важных момента:
- Не с рабочего IP и не с самого сервера. Забаните
127.0.0.1— локальные скрипты (если такие есть) перестанут отправлять почту. Забаните админский адрес — потеряете доступ к WebUI. - Логин несуществующий. Не подставляйте живой ящик: если в Stalwart есть per-account lockout, заблокируете его.
Критерий истины — обрыв соединения, а не наличие записи в BlockedIp. Stalwart может держать бан в памяти, а объект BlockedIp использовать для персистентных и ручных правил.
Откат после эксперимента:
stalwart-cli update Security singleton --field authBanRate='{"count":5,"period":3600000}'
stalwart-cli query BlockedIp --json
stalwart-cli destroy BlockedIp <ID>
stalwart-cli create Action/ReloadSettings --json "{}"
stalwart-cli create Action/ReloadBlockedIps --json "{}"
5.10 Итоговая конфигурация
# auth: 5/час, бан сутки — закрывает и быстрый, и медленный вектор
stalwart-cli update Security singleton --field authBanRate='{"count":5,"period":3600000}' --field authBanPeriod=86400000
# abuse: 15/час, бан сутки — осторожно при рассылках по спискам
stalwart-cli update Security singleton --field abuseBanRate='{"count":15,"period":3600000}' --field abuseBanPeriod=86400000
# loiter: 150/сутки, бан час — мягко, чтобы не наказывать мобильные клиенты
stalwart-cli update Security singleton --field loiterBanPeriod=3600000
# scan: glob-ловушки банят мгновенно, 7 дней
stalwart-cli update Security singleton --field scanBanPeriod=604800000
stalwart-cli create Action/ReloadSettings --json "{}"
stalwart-cli create Action/ReloadBlockedIps --json "{}"
Проверка:
stalwart-cli get Security singleton --json
Ни одного null в *BanPeriod быть не должно.
5.11 Через WebUI иногда удобнее
Все четыре механизма настраиваются в Security → Settings → Automatic IP Banning. И там есть неочевидное преимущество: единицы измерения выбираются из списка — Milliseconds / Hours / Days. Интерфейс сам конвертирует в миллисекунды.
Не нужно считать 86400000 руками и рисковать ошибиться в порядке величины.
Там же в сайдбаре: Security → Blocked IPs — смотреть активные баны и снимать их кнопкой, если случайно заблокировали себя или легитимного отправителя. При экспериментах с порогами это экономит нервы. Allowed IPs — пригодится для ограничения служебных аккаунтов по адресу (раздел 14).

Что запомнить про Auto-Ban
- Уменьшение окна без уменьшения порога увеличивает суточную квоту событий.
10/часразрешает до 216 попыток в сутки против 100 при100/24ч. Контринтуитивно, но арифметика неумолима. - Метрика — «попыток до блокировки», а не «попыток в окне». Бан обрывает последовательность, без учёта этого расчёт врёт в обе стороны.
- Порог — компромисс между двумя векторами атаки и ложными срабатываниями. Одной цифрой все три не оптимизируешь, а два правила в скалярное поле не поместятся.
- Проверяйте все четыре механизма. Легко закрыть auth и abuse, оставив loitering с незаданной длительностью.
- Бан по IP не решает распределённую атаку. Против ботнета с тысяч адресов нужны устойчивые пароли, 2FA и троттлинг на других уровнях.
6. SpamSettings: скоринг и отказ от Greylisting
6.1 Reject вместо «пометить и пропустить»
Было scoreReject = 0.0, то есть отключено. Сервер принимал всё, включая письма с оценкой 30, и просто складывал их в «Спам». Это трата дискового I/O, места и ресурсов спам-фильтра на заведомый мусор.
stalwart-cli update SpamSettings singleton --field scoreReject=15.0
stalwart-cli create Action/ReloadSettings --json "{}"
Теперь откровенный мусор отбивается на этапе SMTP DATA. Порог scoreSpam = 5.0 оставлен — письма между 5 и 15 попадают в «Спам» и доступны пользователю.
6.2 Greylisting: включил, понаблюдал, выключил
Изначально greylistFor был null. Я включил его на 5 минут — классическая рекомендация:
stalwart-cli update SpamSettings singleton --field greylistFor=300000
А потом посмотрел в логи:
05:28:15 remoteIp=74.125.224.45 → RCPT TO greylisted 05:34:17 remoteIp=209.85.160.172 → RCPT TO greylisted
Один и тот же отправитель, Gmail, повторная попытка через шесть минут — с другого IP. И снова отбой.
Механика проста: классический greylisting запоминает триаду (IP, sender, recipient). У Gmail огромный пул исходящих адресов и retry приходит с нового. Триада новая → снова greylist. Доставка растягивается на часы, пока не повезёт попасть на тот же IP.
Я попробовал сделать greylisting условным — отключать его для отправителей, прошедших SPF и DKIM:
error: invalidPatch — Invalid path for Duration
Не вышло. Смотрим схему:
stalwart-cli describe SpamSettings
greylistFor number<duration>? mutable
Это скаляр, а не Expression. Условная логика в это поле не помещается принципиально.
Итоговое решение — отключить:
stalwart-cli update SpamSettings singleton --field greylistFor=null
stalwart-cli create Action/ReloadSettings --json "{}"
Обоснование. Greylisting придуман в эпоху, когда у почтовиков не было ни массового DMARC, ни приличных DNSBL. Сегодня у меня работают: 18 зон DNSBL (Spamhaus ZEN/DBL, SpamCop, Barracuda, SURBL, URIBL и другие), Auto-Ban с жёсткими порогами, DMARC p=reject, валидация SPF/DKIM/ARC. Современные ботнеты умеют делать retry — greylisting их не останавливает. А легитимную почту от крупных провайдеров он тормозит гарантированно.
Результат после отключения:
05:45:53 smtp.rcpt-to → to = "yuri@grib69.ru" (без greylisted) 05:45:53 smtp.dkim-pass / smtp.arc-pass / smtp.dmarc-pass 05:46:10 message-ingest.ham → mailboxId=[0], elapsed=16ms
Шестнадцать миллисекунд на ингест вместо шести минут ожидания.
7. DnsResolver: почему DANE был включён, но не работал
Это самая поучительная находка всей сессии.
В стратегии TLS у меня стояло dane: optional. Я был уверен, что DANE работает. Но при каждой сборке конфигурации сервер писал:
WARN registry.build-warning objectType="DnsResolver"
reason = "The configured DNS resolver cannot validate DNSSEC.
DANE has been disabled to avoid deferring mail.
Ensure the resolver is DNSSEC-capable and reachable over TCP."
Stalwart проверяет, способен ли резолвер валидировать DNSSEC, и если нет — сам отключает DANE, чтобы не задерживать почту. Логика правильная: DANE без DNSSEC бессмысленен, а пытаться валидировать через невалидирующий резолвер значит деферить письма.
Почему так вышло. Резолвер был типа System, то есть ходил в системный DNS — на MikroTik, который форвардил в Cloudflare по DoH. Cloudflare валидирует DNSSEC. Но MikroTik, выступая форвардером, не пробрасывает флаг AD (Authenticated Data) обратно к клиенту. Stalwart видит обычный DNS-ответ без признаков валидации и делает единственно возможный вывод.
Цепочка «валидирующий апстрим за невалидирующим форвардером» не работает. Валидация должна быть либо на самом резолвере, либо явно подтверждена флагом AD.
Решение — ходить в Cloudflare напрямую:

Компромисс. Stalwart перестал использовать кэш MikroTik для своих запросов. Для MTA это некритично — у него есть собственный кэш. MikroTik продолжает обслуживать остальную сеть.
Возможное решение на будущее — поднять локальный валидирующий резолвер. Как вариант unbound в контейнере на MikroTik: нативный DNSSEC, footprint 20–30 МБ RAM, поддержка local-zone для внутренних зон, форвардинг в Cloudflare по DoT на порт 853. Это вернёт и локальные зоны, и кэш, и DANE.
Мораль: проверяйте registry.build-warning после каждого reload. Молчаливое отключение фичи — худший вид отказа, потому что в конфиге всё выглядит включённым.
8. MtaTlsStrategy: таймауты и один открытый вопрос
Смотрим стратегии (помним — по ID, не по имени):
stalwart-cli query MtaTlsStrategy --json stalwart-cli get MtaTlsStrategy jnue90dmacqb --json # default
Что уже было правильно
{ "dane":"optional", "mtaSts":"optional", "startTls":"optional", "allowInvalidCerts":false }
optional — единственно верный режим для публичного MTA. Соблазн поставить required велик, но это сломает доставку на все домены без TLSA-записей и MTA-STS политик, а их большинство. Не делайте так.
Что было неправильно
| Параметр | Было | Стало |
|---|---|---|
mtaStsTimeout | 300000 (5 минут) | 15000 |
tlsTimeout | 180000 (3 минуты) | 60000 |
Пять минут на получение MTA-STS политики — это возможность для недобросовестного получателя держать ваш исходящий воркер занятым пять минут на каждое письмо. Умножьте на размер пула.
stalwart-cli update MtaTlsStrategy jnue90dmacqb --field mtaStsTimeout=15000 --field tlsTimeout=60000
stalwart-cli update MtaTlsStrategy jnue90dmacab --field mtaStsTimeout=15000 --field tlsTimeout=60000
stalwart-cli create Action/ReloadSettings --json "{}"
Открытый вопрос: TLS-downgrade
В MtaOutboundStrategy.tls я обнаружил такую логику:
if retry_num > 0 && last_error == 'tls' → 'invalid-tls'
А стратегия invalid-tls имеет allowInvalidCerts = true. То есть: один сбой TLS — и на второй попытке принимается любой сертификат, включая самоподписанный.
Теоретически это открывает вектор для downgrade-атаки: достаточно один раз помешать TLS-рукопожатию. Практически — я не знаю как часто эта ветка срабатывает в реальности и имеют ли DANE/MTA-STS приоритет над ней.
Я не стал это менять. Менять логику доставки на сервере вслепую, без статистики по срабатываниям — неоправданный риск потерять почту. Сначала понаблюдаю. Если у вас есть данные по этой ветке — буду рад обсудить.
9. MtaOutboundThrottle: дыра, которой не должно быть
stalwart-cli query MtaOutboundThrottle --json
Пусто. Ни одного правила.
Это означает: если у вас уведут пароль от любого ящика, злоумышленник сможет отправить через ваш сервер сколько угодно писем. Итог предсказуем — домен и IP в блэклистах, недели на восстановление репутации.
Смотрим схему:
stalwart-cli describe MtaOutboundThrottle stalwart-cli describe MtaOutboundThrottleKey
stalwart-cli describe MtaOutboundThrottle
MtaOutboundThrottle
Defines an outbound rate limit rule for message delivery.
Fields:
description string<string> mutable
Short description for the throttle
enable boolean mutable
Whether to enable this throttle
key set<enum (see MtaOutboundThrottleKey)> mutable
Optional list of context variables that determine where this throttle should be applied
match object<Expression> mutable
Enable the imposition of concurrency and rate limits only when a specific condition is met
rate object<Rate> mutable
Number of incoming requests over a period of time that the rate limiter will allow
stalwart-cli describe MtaOutboundThrottleKey
MtaOutboundThrottleKey (enum)
Variants:
mx MX Host
remoteIp Remote IP
localIp Local IP
sender Sender
senderDomain Sender Domain
rcptDomain Recipient Domain
Важные детали: поля name нет (только description, enable, key, match, rate). Допустимые ключи: mx, remoteIp, localIp, sender, senderDomain, rcptDomain. И key — это множество, то есть map-объект.
Создаём два правила:
stalwart-cli create MtaOutboundThrottle --field description="Sender limit 200/h" --field enable=true --field 'key={"sender":true}' --field match='{"match":{},"else":"true"}' --field rate='{"count":200,"period":3600000}'
stalwart-cli create MtaOutboundThrottle --field description="Rcpt domain limit 100/h" --field enable=true --field 'key={"rcptDomain":true}' --field match='{"match":{},"else":"true"}' --field rate='{"count":100,"period":3600000}'
stalwart-cli create Action/ReloadSettings --json "{}"
Что это даёт. Первое правило: один отправитель — максимум 200 писем в час. Второе: на один домен-получатель — максимум 100 писем в час. Взломанный ящик физически не сможет разослать сто тысяч писем, а массовая атака на конкретный Gmail упрётся в лимит.
Для сравнения, входящие лимиты у меня уже были настроены и настроены хорошо:
| Правило | Ключ | Лимит |
|---|---|---|
| Sender address to recipient | senderDomain + rcpt | 25 / час |
| Sender IP | remoteIp | 5 / сек |
Первое — против таргетированного спама на конкретного сотрудника. Второе — против массовых рассылок с одного узла.
10. HTTP Security: самый практичный раздел
Здесь я приведу реальный конфиг целиком, потому что это тот случай, когда можно скопировать и адаптировать.
10.1 Исходная проблема
В HAProxy у меня настроен HSTS — но только на фронтенде, который терминирует TLS. Трафик к Stalwart идёт через TCP passthrough по SNI, а значит HAProxy не видит HTTP-слой и не может инжектировать заголовки. Stalwart должен отдавать их сам.
А он не отдавал: enableHsts = false, responseHeaders = {}.
10.2 Rate Limiting
"rateLimitAnonymous": { "count": 100, "period": 60000 },
"rateLimitAuthenticated": { "count": 1000, "period": 60000 }
100 запросов в минуту для анонима — сканер упрётся быстро. 1000 для авторизованного — с запасом для JMAP-клиента, который синхронизирует папки пачками запросов.
10.3 HSTS и защитные заголовки
stalwart-cli update Http singleton --field enableHsts=true
stalwart-cli update Http singleton --field 'responseHeaders={"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","Referrer-Policy":"strict-origin-when-cross-origin","Permissions-Policy":"camera=(), microphone=(), geolocation=()"}'
stalwart-cli create Action/ReloadSettings --json "{}"
Что каждый заголовок делает:
| Заголовок | От чего защищает |
|---|---|
X-Content-Type-Options: nosniff | MIME-sniffing: браузер не станет угадывать тип и исполнять загруженный «текст» как скрипт |
X-Frame-Options: DENY | Clickjacking: веб-клиент нельзя встроить в iframe на чужом сайте |
Referrer-Policy: strict-origin-when-cross-origin | Утечка путей и параметров через Referer на внешние сайты |
Permissions-Policy: camera=(), microphone=(), geolocation=() | Отключает доступ к API браузера, которые почтовому клиенту не нужны |
Про HSTS. Включение означает, что браузер будет требовать HTTPS для этого хоста в течение срока действия политики. Убедитесь, что автопродление сертификата работает надёжно. У меня ACME через Cloudflare DNS-01 с wildcard — риск минимальный.
10.4 allowedEndpoints — whitelist путей и подсетей
Самая интересная часть. Вот реальное выражение:
"allowedEndpoints": {
"match": {
"0": {
"if": "is_ip_in_cidr(remote_ip, '10.24.0.0/16') || is_ip_in_cidr(remote_ip, '172.24.0.3/32')",
"then": "200"
},
"1": {
"if": "contains(['jmap','autodiscover','autoconfig','.well-known','mta-sts.txt','robots.txt','account','webmail','auth'], split(path,'/')[1]) || (split(path,'/')[1] == 'api' && split(path,'/')[2] == 'auth')",
"then": "200"
}
},
"else": "403"
}
Разбираем логику:
Правило 0. Если запрос пришёл из доверенной подсети 10.24.0.0/16 (внутренняя сеть) или с конкретного адреса 172.24.0.3/32 — разрешаем всё. Это административный доступ.
Правило 1. Для всех остальных — только белый список путей первого уровня:
jmap— протокол почтового клиентаautodiscover,autoconfig— автонастройка клиентов.well-known,mta-sts.txt— служебные политики (обязательно публичные!)robots.txt— для поисковиковaccount,webmail,auth— веб-клиент и аутентификация- отдельно
api/auth— только этот путь подapi, не весьapi
else → 403. Всё остальное отбивается.
Практический эффект: административные эндпоинты снаружи не видны. Сканер, ищущий /api/settings или /admin, получает 403 и не может даже определить, что там есть.
Важно: .well-known и mta-sts.txt обязаны быть публичными, иначе сломается MTA-STS и ACME-валидация. Не закройте их случайно.
10.5 clientIpSource за прокси
"clientIpSource": "peer", "forwardedHops": 1
peer означает «брать IP из транспортного соединения». При работе через HAProxy с PROXY protocol это корректно: PROXY-заголовок подменяет адрес пира на реальный клиентский ещё до HTTP-слоя.
Альтернатива — брать IP из X-Forwarded-For. Но документация прямо предупреждает: при выборе forwarding-заголовка листенер должен быть доступен только через доверенные прокси, иначе любой клиент, подключившийся напрямую, заявит произвольный адрес. Это отравило бы и логи и Auto-Ban.
peer — безопасный выбор.
10.6 Остальное
"usePermissiveCors": false, "redirectRoot": "/account"
CORS не разрешает произвольные origin. Корень редиректит на веб-клиент.
Итоговый конфиг
{
"rateLimitAuthenticated": { "count": 1000, "period": 60000 },
"rateLimitAnonymous": { "count": 100, "period": 60000 },
"allowedEndpoints": {
"match": {
"0": {
"if": "is_ip_in_cidr(remote_ip, '10.24.0.0/16') || is_ip_in_cidr(remote_ip, '172.24.0.3/32')",
"then": "200"
},
"1": {
"if": "contains(['jmap','autodiscover','autoconfig','.well-known','mta-sts.txt','robots.txt','account','webmail','auth'], split(path,'/')[1]) || (split(path,'/')[1] == 'api' && split(path,'/')[2] == 'auth')",
"then": "200"
}
},
"else": "403"
},
"enableHsts": true,
"usePermissiveCors": false,
"responseHeaders": {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
},
"clientIpSource": "peer",
"forwardedHops": 1,
"redirectRoot": "/account",
"id": "singleton"
}
11. MTA-STS: из testing в enforce
Было mode = testing. В этом режиме политика публикуется, но принимающие серверы её не применяют — только шлют отчёты. Защиты ноль.
stalwart-cli update MtaSts singleton --field mode=enforce
stalwart-cli create Action/ReloadSettings --json "{}"
Но политика должна быть доступна по адресу https://mta-sts.grib69.ru/.well-known/mta-sts.txt. А мой HAProxy этот SNI не пропускал (забыл сразу добавить):
# было acl req_sni_is_stalwart req.ssl_sni -i mail.grib69.ru # стало acl req_sni_is_stalwart req.ssl_sni -i mail.grib69.ru mta-sts.grib69.ru
Wildcard-сертификат *.grib69.ru покрывает хост, отдельно ничего выпускать не нужно.
Проверка:
curl -sS https://mta-sts.grib69.ru/.well-known/mta-sts.txt
version: STSv1 mode: enforce max_age: 604800 mx: mail.grib69.ru
Не забудьте про TXT-запись в DNS _mta-sts.grib69.ru с v=STSv1; id=... — без неё политики не обнаружат.
12. Защита noreply-адресов через Sieve
Задача: письма на wp-noreply@, noreply@, no-reply@ должны отбиваться, но создавать три полноценных ящика ради этого — расточительство и лишняя поверхность атаки.
Решение — системный Sieve-скрипт, привязанный к этапу RCPT TO.
12.1 Как я искал имя поля
Здесь я честно потратил четыре попытки:
--field script=... → error: unknown field: script --field source=... → error: unknown field: source --field content=... → error: unknown field: content
Помог только describe:
stalwart-cli describe SieveSystemScript
contents string<sieve> Content of the script description string<string>? Description isActive boolean Whether the script is active name string<string> Name
Поле называется contents, во множественном числе.
12.2 Почему только через файл
Даже с правильным именем прямая передача через --field падает:
Failed to compile system Sieve script: Expected token "instruction" but found "require [\"reject\", \"envelope\"]; ..."
PowerShell съедает экранирование и валидатор получает буквально require [\"reject\"...] вместо require ["reject"...]. Тип string<sieve> требует чистый Sieve без лишнего экранирования.
Файл noreply-script.json:
{
"name": "noreply-handler",
"description": "Reject incoming mail to noreply addresses",
"isActive": true,
"contents": "require [\"reject\", \"envelope\"];\nif envelope :is \"to\" [\"wp-noreply@grib69.ru\", \"noreply@grib69.ru\", \"no-reply@grib69.ru\"] {\n reject \"This address is not monitored. Please do not reply to this message.\";\n stop;\n}"
}
stalwart-cli create SieveSystemScript --json (Get-Content .\noreply-script.json -Raw) stalwart-cli query SieveSystemScript --json
12.3 Привязка к RCPT TO
stalwart-cli update MtaStageRcpt singleton --field script='{"match":{"0":{"if":"rcpt == \"wp-noreply@grib69.ru\" || rcpt == \"noreply@grib69.ru\" || rcpt == \"no-reply@grib69.ru\"","then":"\"noreply-handler\""}},"else":"false"}'
stalwart-cli create Action/ReloadSettings --json "{}"
12.4 Проверка боем
Отправил письмо с Gmail на wp-noreply@grib69.ru. Получил DSN:
X-Failed-Recipients: wp-noreply@grib69.ru 503 5.5.3 This address is not monitored. Please do not reply to this message.
Кастомный текст на месте, отказ постоянный, письмо не попало ни в очередь, ни в базу. Backscatter исключён, потому что отказ происходит на этапе SMTP-диалога, а не после приёма.
Особенность логирования: при reject в MtaStageRcpt.script событие smtp.rcpt-to может не записаться — сессия прерывается раньше. Я сначала решил, что скрипт не работает и полез проверять. Не пугайтесь.
13. Загадка: DKIM Ed25519 и Gmail
Это единственный сюжет, где я не дошёл до однозначного ответа. Расскажу как есть — с проверенными фактами и непроверенными гипотезами.
Наблюдение
Stalwart подписывает исходящие письма двумя алгоритмами: RSA и Ed25519. Результаты проверки у разных получателей:
| Получатель | DKIM RSA | DKIM Ed25519 |
|---|---|---|
| Mail.ru | pass | pass |
| Mail-Tester | pass | pass |
| Gmail | pass | neutral (no key) |
Заголовок Gmail:
dkim=neutral (no key) header.i=@grib69.ru header.s=v1-ed25519-20261109 dkim=pass header.i=@grib69.ru header.s=v1-rsa-20261109 spf=pass ... dmarc=pass (p=REJECT sp=REJECT dis=NONE)
Гипотеза 1: ключ в DNS обрезан — отброшена
Первое, что я подумал: значение p= выглядит подозрительно коротким по сравнению с приватным ключом в конфиге.
Проверил криптографически: распарсил PKCS#8-структуру приватного ключа, извлёк публичную часть, сравнил с тем, что в DNS.
Публичный ключ в DNS: dnleFf3dpajN9RuUHp+ZlH/igXFWJYdAHEAgtDe8ErQ= Декодировано: 32 байта Производная от приватного ключа: совпадает побайтово
Ed25519-ключ — ровно 32 байта, в base64 это 44 символа. Всё корректно. Моя визуальная оценка была ошибочной — я сравнивал base64 приватного ключа (который содержит и seed, и обёртку ASN.1) с base64 публичного.
Урок: проверяйте криптографически, а не глазами по длине строки.
Гипотеза 2: отрицательный кэш Gmail — отброшена
Второе объяснение: Gmail закэшировал NXDOMAIN с момента до создания записи.
Возражение нашлось сразу: селектор называется v1-ed25519-20261109 — дата создания 9 ноября. Тесты шли 11–12 ноября. Три дня. Максимальный TTL негативного кэша по RFC 2308 ограничен минимумом SOA, у Cloudflare это минуты.
Кэш тут ни при чём.
Гипотеза 3: тег h=sha256 — не проверена
v=DKIM1; k=ed25519; h=sha256; p=dnleFf3dpajN9RuUHp+ZlH/igXFWJYdAHEAgtDe8ErQ=
Тег h= в DKIM-записи необязательный. Предположение: строгий парсер Google может отбрасывать запись при определённой комбинации k=ed25519 и h=sha256.
Я это проверять не стал. Mail-Tester и Mail.ru принимают запись с этим тегом без проблем. Никаких данных о поведении парсера Gmail у меня нет.
Если хотите поэкспериментировать — удаление необязательного тега безопасно:
v=DKIM1; k=ed25519; p=dnleFf3dpajN9RuUHp+ZlH/igXFWJYdAHEAgtDe8ErQ=
Проверять результат стоит через Google Admin Toolbox Dig — чтобы видеть зону глазами инфраструктуры Google, а не своего резолвера.
Почему это не блокирует доставку
Важный момент, который стоит проговорить: DMARC проходит. Достаточно одной валидной DKIM-подписи, выровненной с доменом From. RSA-подпись валидна, DMARC pass, политика p=reject соблюдена.
neutral для второй подписи — это не fail. Это «не удалось проверить». На доставляемость оно не влияет критически.
Что я вынес
- Разные провайдеры используют разные резолверы и разные парсеры. Расхождение в результатах — нормальное явление, а не обязательно ваша ошибка.
- Один независимый верификатор — мало. Три (Mail.ru, Mail-Tester, Gmail) дают картину.
- Не всякую аномалию нужно немедленно «чинить». Я не стал менять работающую конфигурацию ради гипотезы.
- В принципе
v1-ed25519-20261109можно было бы вообще удалить, ни на что это не повлияет, но я решил пока оставить.
14. Служебный аккаунт для CMS: наименьшие привилегии
WordPress отправляет почту через FluentSMTP. Схема, к которой я пришёл:
| Роль | Значение |
|---|---|
| SMTP AUTH | wp-noreply@grib69.ru |
| From | wp-noreply@grib69.ru |
| Приём писем | отбивается Sieve-скриптом |
Stalwart разрешает авторизованному пользователю указывать любой адрес своего домена в From, а DKIM привязан к домену, не к логину. Так что отдельный «технический» ящик для авторизации не нужен.
Роль только на отправку
Смотрим схему:
stalwart-cli describe Role
stalwart-cli describe Role
Role
Defines a named set of permissions that can be assigned to accounts and groups.
Fields:
description string<string> mutable
Description of the role
disabledPermissions set<enum (see Permission)> mutable
List of permissions that are explicitly disabled, even if they would be inherited through other roles or groups. This takes precedence over enabled permissions.
enabledPermissions set<enum (see Permission)> mutable
List of permissions that are explicitly enabled.
memberTenantId id<Tenant>? mutable
Identifier for the tenant this role belongs to
roleIds set<id<Role>> mutable
List of roles this role extends
Filters:
description text Description
memberTenantId id<Tenant> Tenant
Поля: description, enabledPermissions, disabledPermissions, memberTenantId, roleIds. Поля name нет — попытка передать даёт invalidPatch: Invalid property name. Название роли — это её description.
Из справочника Permission (а там больше 400 пунктов) для SMTP-отправки нужны ровно два:
authenticate— вход на серверemailSend— отправка почты
Всё остальное — emailReceive, imapAuthenticate, pop3Authenticate, sieveAuthenticate, весь JMAP — не выдаём.
Файл role-smtp-sender.json:
{
"description": "smtp-sender-only",
"enabledPermissions": {
"authenticate": true,
"emailSend": true
},
"disabledPermissions": {}
}
stalwart-cli create Role --json (Get-Content .\role-smtp-sender.json -Raw) stalwart-cli query Role --json
Новая роль получит генерируемый ID — его и указываем при создании аккаунта.
Аккаунт
Через WebUI: Accounts → Create Account. Имя wp-noreply, домен, роль smtp-sender-only, пароль 32+ символа.
Что это даёт
Если пароль утечёт, злоумышленник сможет только отправлять — и то в пределах 200 писем в час из раздела 9. Читать почту, подключаться по IMAP, управлять фильтрами, менять настройки — нельзя.
Ограничение по IP
Логичный следующий шаг — разрешить авторизацию только с локального адреса. Механизм есть: объект AllowedIp.
Но здесь, если вы используете контейнеризацию, может ждать ловушка. Если WordPress в Docker-контейнере, то 127.0.0.1 внутри него — это он сам, а не хост. Реальный remoteIp в логах Stalwart будет адресом шлюза Docker-сети. Whitelist нужно строить по фактическому адресу:
stalwart-cli query Log --json | Select-String "auth.success|auth.fail"
И, как всегда, сначала describe AllowedIp.
15. Инструментарий: дамп конфигурации
Штатной команды «выгрузить всё» в stalwart-cli нет. Для аудита и миграции я написал PowerShell-скрипт.
Два режима
.\dump-stalwart.ps1 # аудит: без секретов .\dump-stalwart.ps1 -WithSecrets # бэкап: с приватными ключами
В режиме бэкапа скрипт включает includeSecrets, выгружает, и гарантированно выключает обратно через try...finally — даже если упадёт на середине.
Ключевые решения в скрипте
UTF-8 без BOM. Стандартный Out-File -Encoding utf8 в Windows PowerShell 5.1 добавляет BOM, который ломает парсинг JSON в Linux. Используем .NET:
[System.IO.File]::WriteAllText($filePath, $data, [System.Text.Encoding]::UTF8)
Абсолютные пути. Метод WriteAllText не понимает относительные пути PowerShell. Первый запуск упал с Could not find a part of the path. Лечится так:
$OutDir = Join-Path $PWD $DirName
Полные объекты, а не summary. query возвращает только ID и описание. Чтобы получить полную конфигурацию, для каждой коллекции делаем query → список ID → get по каждому.
Изоляция ошибок. Каждый объект в своём try/catch — отсутствующий ClusterNode на одноузловой установке не должен ронять весь дамп.
Что выгружаем
59 синглтонов и 50 типов коллекций. Критичное, что легко забыть:
Certificate— TLS-сертификаты с приватными ключамиDkimSignature— DKIM-ключиSymmetricKey— ключи шифрования blob-хранилищаAcmeProvider— токены API провайдера DNSApiKey,AppPassword,Passkey,OAuthClient— креденшелыDomain,Directory,MailingList— конфигурация доменов и каталоговSpamRule,SpamTag,SpamFileExtension— правила фильтраMtaRoute,MtaConnectionStrategy,MtaDeliverySchedule— маршрутизация
Что НЕ выгружаем
| Объект | Почему |
|---|---|
Log, Metric, Task | runtime-телеметрия |
Trace | требует Enterprise |
QueuedMessage | очередь пересоздастся |
ArchivedItem | живёт в BlobStore |
*ExternalReport, *InternalReport | отчёты |
SpamTrainingSample | ML-выборки, слишком объёмные |
IndexQueueStatus, OAuthGrant | временное состояние |
MemoryLookupKey, MemoryLookupKeyValue | in-memory кэш: DNSBL, MTA-STS, счётчики троттлинга. Обнуляется при рестарте |
Последний пункт я поначалу включил по ошибке — просто загребал все коллекции из describe. Кэш выгружать бессмысленно: он пересобирается сам, а значения устаревают за минуты.
Ссылка на сам скрипт dump-stalwart.ps1
Полный бэкап — это другое
CLI-дамп — это конфигурация. Письма, blob-данные и индексы в него не попадают. У меня бэкенд — RocksDB в /var/lib/stalwart/, и для полного бэкапа нужен файловый снимок:
systemctl stop stalwart tar -I zstd -cvf /backup/stalwart-$(date +%F_%H-%M).tar.zst /var/lib/stalwart/ systemctl start stalwart systemctl status stalwart --no-pager
Остановка на пару секунд — самый надёжный способ получить консистентный снимок embedded-хранилища. Копировать работающий RocksDB через cp или rsync нельзя: можно поймать середину записи WAL и получить битую копию.
Если простой недопустим — LVM-снимки.
Безопасность дампов. В режиме -WithSecrets в JSON лежат приватные DKIM-ключи, TLS-ключи, токены ACME и хэши паролей. chmod 600, шифрованный архив, никогда в Git.
16. Грабли: сводная таблица
Всё, на чём я споткнулся:
| Ошибка | Правильно |
|---|---|
update ... --where name=smtp | Флага нет. update <Object> <ID> |
update MtaStageData --field ... | Синглтоны требуют явный ID: update MtaStageData singleton ... |
get MtaTlsStrategy default | По ID: get MtaTlsStrategy jnue90dmacqb |
Скаляр в object<Expression> | JSON {"match":{},"else":"значение"} |
key=["sender"] | Множества — map: key={"sender":true} |
Условие в greylistFor | Тип number<duration>?, выражений не поддерживает |
SieveSystemScript.script/source/content | Поле contents, тип string<sieve> |
Sieve через --field | Только JSON-файл |
Role.name, MtaOutboundThrottle.name | Такого поля нет |
| «DANE включён — значит работает» | Проверять registry.build-warning |
| «Ключ DKIM обрезан» | Проверять криптографически |
| «Подождать кэша» | Сверять дату в селекторе с текущей |
Забытый reload | Action/ReloadSettings после каждого блока |
Out-File -Encoding utf8 для JSON | .NET WriteAllText — без BOM |
Относительный путь в WriteAllText | Join-Path $PWD |
И отдельно — про кириллицу. При выгрузке описания аккаунтов я получил ╨о╤А╨╕╨╣ ╨У╤А╨╕╨▒╨╛╨▓ вместо «Юрий Грибов». Это UTF-8-байты, прочитанные как KOI8-R. Лечится принудительной установкой кодировки в начале скрипта:
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 $PSDefaultParameterValues['*:Encoding'] = 'utf8'
На доставку почты это не влияет никак — только на читаемость в консоли. Но для служебных описаний всё равно лучше использовать латиницу: меньше сюрпризов.
17. Итоги
Что изменено
| Компонент | Изменение | Подтверждение |
|---|---|---|
| NetworkListener | tlsTimeout, backlog, maxConnections | применено |
| MtaStageData | 100 МБ → 50 МБ | применено |
| SenderAuth | ARC disable → relaxed | smtp.arc-pass |
| Security | брутфорс 10/ч → бан 24ч, сканеры 7 дней | применено |
| SpamSettings | scoreReject 15.0 | применено |
| SpamSettings | Greylisting отключён | отключен по умолчанию |
| MtaSts | testing → enforce | curl отдаёт политику |
| HAProxy | SNI mta-sts | curl проходит |
| DnsResolver | System → Cloudflare DoH | warning исчез |
| MtaTlsStrategy | 5 мин → 15 с, 3 мин → 60 с | применено |
| MtaInboundSession | idle 5 мин → 120 с, transfer 250 → 100 МБ | применено |
| MtaOutboundThrottle | 2 правила: 200/ч, 100/ч | созданы |
| MtaStageRcpt | maxRecipients 50/20 | применено |
| Http | HSTS + 4 заголовка + whitelist | применено |
| Sieve noreply | reject 3 адресов | DSN 503 5.5.3 |
| Role + Account | smtp-sender-only | созданы |
Что осталось открытым
- Загадка Ed25519 у Gmail — гипотеза с
h=не проверена - Ветка TLS-downgrade в
MtaOutboundStrategy— наблюдаю, не трогаю - unbound на MikroTik — вернуть локальные зоны и кэш. Нужно ли?
- Google Postmaster Tools — мониторинг репутации
Регламент
# после любых изменений
stalwart-cli create Action/ReloadSettings --json "{}"
# мониторинг доставки (норма elapsed: 700–3000 мс)
stalwart-cli query Log --json | Select-String "delivery.delivered|delivery.connect-error"
# очередь
stalwart-cli query QueuedMessage --json
# блэклисты dig +short 30.75.195.109.zen.spamhaus.org dig +short 30.75.195.109.b.barracudacentral.org # политики и подписи curl -sS https://mta-sts.grib69.ru/.well-known/mta-sts.txt dig TXT v1-ed25519-20261109._domainkey.grib69.ru +short dig TXT _dmarc.grib69.ru +short
И после завершения диагностики:
stalwart-cli update SystemSettings singleton --field includeSecrets=false
stalwart-cli create Action/ReloadSettings --json "{}"
Заключение
Главный вывод: работающий сервер и правильно настроенный сервер — разные вещи.
Почта ходила и до тюнинга. Но исходящих лимитов не было, DANE был выключен молча, брутфорс фактически не ограничивался, а HSTS отсутствовал. Ни один из этих дефектов не проявлялся в штатном режиме — все они ждали своего инцидента.
Три вещи, которые реально помогли:
- Читать конфигурацию целиком, объект за объектом. Не «то, что помню, что настраивал», а полный дамп с
describeпо каждому типу. - Читать предупреждения, а не только ошибки.
registry.build-warningпро DANE мог бы оставаться незамеченным очень долго.
Stalwart — хороший инструмент с продуманной моделью конфигурации. Но модель эта большая (больше 130 типов объектов), и дефолты в ней местами очень либеральные. Пройтись по ней руками стоит.
Проверьте свой MTA сегодня. Начните с query MtaOutboundThrottle — вдруг там тоже пусто.
Все команды проверены на Stalwart 1.1.0 Community. Значения подобраны под конкретный профиль нагрузки — адаптируйте под свой.
ИИ-хейтерам! Данная статья написана при активном участии ИИ, при своём косноязычии я вряд ли потянул такой объём, а бОльшую часть вообще забыл бы. Для меня важно что я законспектировал то что я хотел. И законспектировал со своими подробными рассуждениями, по горячим следам. В противном случае это всё улетучилось бы из памяти уже за пару дней. Так что для меня важнее содержание, а не то какими инструментами кто пользуется — notepad, word или ChatGPT