Stalwart Mail Server: настройка от «работает» к «работает правильно»

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

Введение

Stalwart mail delivery

У меня развёрнут 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>Число в миллисекундах. Выражения не поддерживает
PowerShellJSON оборачивать в одинарные кавычки
Сложный контентТолько через файл: --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
ListenerID
smtpjnue90gaaeqb
submissionsjnue90gaafab
imapsjnue90gaafqb
pop3sjnue90gaagab
sievejnue90gaagqb
httpjnue90gaahqb
httpsjnue90gaahab

Применяем:

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
ПараметрМеханизмЧто считаетПорогДлительность бана
authBanRateauthнеудачные логины100 попыток / 24 часаnull
abuseBanRateabuserelay + failed RCPT TO35 / 24 часаnull
loiterBanRateloiterвисящие соединения150 / 24 чnull
scanBanRatescanобращения к путям-ловушкам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 = поле не задано, и заполнять его надо.

Stalwart Security Auto-Ban Rate-Duration

Что именно делает сервер при незаданной длительности — не банит вовсе или применяет внутренний дефолт — я не проверял. Для практики это неважно: поле пустое, его нужно заполнить.

5.3 Ловушка: уменьшение окна не всегда ужесточение

Интуитивно кажется, что переход от «100 попыток за сутки» к «10 попыткам за час» — очевидное ужесточение. Окно меньше, порог ниже, реакция быстрее.

Но есть нюанс. В сутках 24 часов, и 10 × 24 = 240. Формально новый порог разрешает больше событий в сутки, чем старый. Разберём, когда это важно, а когда нет.

Считать надо «попыток до блокировки»

Ключевой момент: атакующий не продолжает работу после срабатывания порога. Забаненный IP не может даже установить соединение — оно отбрасывается до SMTP-диалога. Бан обрывает последовательность, а не просто считает её.

Поэтому корректная метрика — не «сколько попыток разрешено в окне», а «сколько атакующий успеет сделать до блокировки».

Сценарий А: быстрый брутфорс

Атакующий долбит словарь с одного IP, 50 попыток в минуту.

100/24ч, бан null10/1ч, бан 24ч
Попыток до срабатывания10010
Время до реакции~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 ч (исходный)100100—
20 / час20456❌ по медленному хуже
10 / час10216❌ по медленному хуже
6 / час6120❌ по медленному хуже
5 / час596✅ по обоим лучше
3 / час348✅ по обоим лучше

Вывод: уменьшать окно нужно вместе с порогом. Иначе суточная квота событий растёт.

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

Три важных момента:

  1. Не с рабочего IP и не с самого сервера. Забаните 127.0.0.1 — локальные скрипты (если такие есть) перестанут отправлять почту. Забаните админский адрес — потеряете доступ к WebUI.
  2. Логин несуществующий. Не подставляйте живой ящик: если в 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).

Stalwart Security Blocked-IP

Что запомнить про Auto-Ban

  1. Уменьшение окна без уменьшения порога увеличивает суточную квоту событий. 10/час разрешает до 216 попыток в сутки против 100 при 100/24ч. Контринтуитивно, но арифметика неумолима.
  2. Метрика — «попыток до блокировки», а не «попыток в окне». Бан обрывает последовательность, без учёта этого расчёт врёт в обе стороны.
  3. Порог — компромисс между двумя векторами атаки и ложными срабатываниями. Одной цифрой все три не оптимизируешь, а два правила в скалярное поле не поместятся.
  4. Проверяйте все четыре механизма. Легко закрыть auth и abuse, оставив loitering с незаданной длительностью.
  5. Бан по 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 DNS Resolver

Компромисс. 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 политик, а их большинство. Не делайте так.

Что было неправильно

ПараметрБылоСтало
mtaStsTimeout300000 (5 минут)15000
tlsTimeout180000 (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 recipientsenderDomain + rcpt25 / час
Sender IPremoteIp5 / сек

Первое — против таргетированного спама на конкретного сотрудника. Второе — против массовых рассылок с одного узла.

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: nosniffMIME-sniffing: браузер не станет угадывать тип и исполнять загруженный «текст» как скрипт
X-Frame-Options: DENYClickjacking: веб-клиент нельзя встроить в 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 RSADKIM Ed25519
Mail.rupasspass
Mail-Testerpasspass
Gmailpassneutral (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. Это «не удалось проверить». На доставляемость оно не влияет критически.

Что я вынес

  1. Разные провайдеры используют разные резолверы и разные парсеры. Расхождение в результатах — нормальное явление, а не обязательно ваша ошибка.
  2. Один независимый верификатор — мало. Три (Mail.ru, Mail-Tester, Gmail) дают картину.
  3. Не всякую аномалию нужно немедленно «чинить». Я не стал менять работающую конфигурацию ради гипотезы.
  4. В принципе v1-ed25519-20261109 можно было бы вообще удалить, ни на что это не повлияет, но я решил пока оставить.

14. Служебный аккаунт для CMS: наименьшие привилегии

WordPress отправляет почту через FluentSMTP. Схема, к которой я пришёл:

РольЗначение
SMTP AUTHwp-noreply@grib69.ru
Fromwp-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 провайдера DNS
  • ApiKey, AppPassword, Passkey, OAuthClient — креденшелы
  • Domain, Directory, MailingList — конфигурация доменов и каталогов
  • SpamRule, SpamTag, SpamFileExtension — правила фильтра
  • MtaRoute, MtaConnectionStrategy, MtaDeliverySchedule — маршрутизация

Что НЕ выгружаем

ОбъектПочему
Log, Metric, Taskruntime-телеметрия
Traceтребует Enterprise
QueuedMessageочередь пересоздастся
ArchivedItemживёт в BlobStore
*ExternalReport, *InternalReportотчёты
SpamTrainingSampleML-выборки, слишком объёмные
IndexQueueStatus, OAuthGrantвременное состояние
MemoryLookupKey, MemoryLookupKeyValuein-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 обрезан»Проверять криптографически
«Подождать кэша»Сверять дату в селекторе с текущей
Забытый reloadAction/ReloadSettings после каждого блока
Out-File -Encoding utf8 для JSON.NET WriteAllText — без BOM
Относительный путь в WriteAllTextJoin-Path $PWD

И отдельно — про кириллицу. При выгрузке описания аккаунтов я получил ╨о╤А╨╕╨╣ ╨У╤А╨╕╨▒╨╛╨▓ вместо «Юрий Грибов». Это UTF-8-байты, прочитанные как KOI8-R. Лечится принудительной установкой кодировки в начале скрипта:

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$PSDefaultParameterValues['*:Encoding'] = 'utf8'

На доставку почты это не влияет никак — только на читаемость в консоли. Но для служебных описаний всё равно лучше использовать латиницу: меньше сюрпризов.

17. Итоги

Что изменено

КомпонентИзменениеПодтверждение
NetworkListenertlsTimeout, backlog, maxConnectionsприменено
MtaStageData100 МБ → 50 МБприменено
SenderAuthARC disable → relaxedsmtp.arc-pass
Securityбрутфорс 10/ч → бан 24ч, сканеры 7 днейприменено
SpamSettingsscoreReject 15.0применено
SpamSettingsGreylisting отключёнотключен по умолчанию
MtaStstesting → enforcecurl отдаёт политику
HAProxySNI mta-stscurl проходит
DnsResolverSystem → Cloudflare DoHwarning исчез
MtaTlsStrategy5 мин → 15 с, 3 мин → 60 сприменено
MtaInboundSessionidle 5 мин → 120 с, transfer 250 → 100 МБприменено
MtaOutboundThrottle2 правила: 200/ч, 100/чсозданы
MtaStageRcptmaxRecipients 50/20применено
HttpHSTS + 4 заголовка + whitelistприменено
Sieve noreplyreject 3 адресовDSN 503 5.5.3
Role + Accountsmtp-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 отсутствовал. Ни один из этих дефектов не проявлялся в штатном режиме — все они ждали своего инцидента.

Три вещи, которые реально помогли:

  1. Читать конфигурацию целиком, объект за объектом. Не «то, что помню, что настраивал», а полный дамп с describe по каждому типу.
  2. Читать предупреждения, а не только ошибки. registry.build-warning про DANE мог бы оставаться незамеченным очень долго.

Stalwart — хороший инструмент с продуманной моделью конфигурации. Но модель эта большая (больше 130 типов объектов), и дефолты в ней местами очень либеральные. Пройтись по ней руками стоит.

Проверьте свой MTA сегодня. Начните с query MtaOutboundThrottle — вдруг там тоже пусто.

Все команды проверены на Stalwart 1.1.0 Community. Значения подобраны под конкретный профиль нагрузки — адаптируйте под свой.

ИИ-хейтерам! Данная статья написана при активном участии ИИ, при своём косноязычии я вряд ли потянул такой объём, а бОльшую часть вообще забыл бы. Для меня важно что я законспектировал то что я хотел. И законспектировал со своими подробными рассуждениями, по горячим следам. В противном случае это всё улетучилось бы из памяти уже за пару дней. Так что для меня важнее содержание, а не то какими инструментами кто пользуется — notepad, word или ChatGPT

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *