Переезд с Postfix на OpenSMTPD: один smtpd.conf вместо тысячи параметров
Честный разбор миграции реального виртуального хостинга с Postfix на OpenSMTPD: минималистичный smtpd.conf, виртуальные ящики через LMTP, submission на 587, DKIM через фильтры и почему меньшая площадь конфигурации означает меньше ночных инцидентов.
EvilMail Team9 июля 2026 г.11 мин чтения
Полночь, тикет о том, что клиент не получает письма. Вы открываете /etc/postfix/main.cf, правите virtual_mailbox_maps, добавляете новый домен — и уходите спать. Утром выясняется: письма тихо уходили в bounce всю ночь, потому что вы отредактировали текстовый источник, но забыли postmap — и Postfix продолжал читать старую бинарную .db-карту. Ни ошибки в логе, ни падения сервиса. Просто рассинхрон между тем, что вы написали, и тем, что MTA видит.
Реальная стоимость Postfix — не производительность. Он гоняет миллионы писем и не потеет. Стоимость — когнитивная нагрузка. Чтобы держать сервер в голове, нужно одновременно помнить две несовместимые грамматики (key=value в main.cf и позиционные колонки в master.cf), плюс набор бинарных карт, которые надо руками пересобирать после каждой правки. Каждая из этих вещей по отдельности нормальна. Их взаимодействие — источник тихих отказов.
Две грамматики, которые надо держать в голове
Запустите postconf -n на любом рабочем сервере. На виртуальном хостинге это обычно 40–60 переопределений поверх дефолтов, а всего у Postfix около 900 параметров. Синтаксис плоский, key = value, читается по строке.
Дальше открываете master.cf, и правила игры меняются. Здесь колонки: service type private unpriv chroot wakeup maxproc command. Позиция значима, - означает «дефолт», и перепутать колонку chroot с wakeup — вопрос одного невнимательного вечера. Это второй язык поверх первого.
Поверх — карты. virtual_mailbox_domains, virtual_mailbox_maps, virtual_alias_maps, transport_maps, smtpd_sender_login_maps. Каждая ссылается на текстовый файл, который надо скомпилировать в .db через postmap. Забыли пересобрать — работает старая версия. И вечная путаница: virtual_alias_maps переписывает адрес получателя, virtual_mailbox_maps определяет, что ящик локальный. Перепутали — либо петля, либо User unknown.
А DKIM в этой модели живёт вообще снаружи, через milter-цепочку к OpenDKIM по отдельному сокету. Ещё один демон, ещё один конфиг, ещё одна точка отказа.
Модель OpenSMTPD: listen → match → action
OpenSMTPD переворачивает подход. Весь маршрутизирующий рантайм — это один /etc/smtpd.conf, который читается сверху вниз как правила pf. Грамматика, которую ввёл OpenSMTPD 6.4 (октябрь 2018) и которая полностью вытеснила старый accept/reject синтаксис, стоит на трёх примитивах:
`listen on` — что и на каком порту слушаем, с каким TLS и фильтрами.
`action` — что делать с письмом: доставить локально, отдать по LMTP, зарелеить наружу.
`match` — условие, которое связывает входящее письмо с action.
Плюс два вспомогательных: `table` (источники данных — домены, ящики, пароли) и `pki` (сертификаты). Правила match оцениваются строго сверху вниз, первое совпадение выигрывает — ровно как в файрволе. Никаких скрытых приоритетов, никакого второго конфигурационного языка. Прочитал файл сверху вниз — знаешь весь маршрут письма.
Инвентаризация перед переездом
Хороший перенос — механический. Всё, что нужно, уже лежит в работающем Postfix, задача — вытащить это и разложить по примитивам OpenSMTPD.
Домены.postconf -n | grep -E 'mydestination|virtual_mailbox_domains' — список доменов, которые сервер обслуживает.
Ящики и алиасы. Содержимое файлов за virtual_mailbox_maps (адрес → путь ящика) и virtual_alias_maps (адрес → адрес). Это будущая таблица virtuals.
Транспорт доставки. Скорее всего virtual_transport = lmtp:unix:/run/dovecot/lmtp. Сокет уже есть, Dovecot трогать не будем.
TLS.smtpd_tls_cert_file и smtpd_tls_key_file — пути переносятся в pki как есть.
Submission. Порт 587, блок submission в master.cf, механизмы SASL. Станет listen ... port 587 auth.
Таблица соответствия держится в голове целиком:
Postfix
OpenSMTPD
virtual_mailbox_domains
table domains
virtual_mailbox_maps / virtual_alias_maps
table virtuals
smtpd_tls_cert_file / key_file
pki ... cert / key
smtpd_sasl_*
auth <credentials>
virtual_transport = lmtp:...
action ... lmtp
smtp_helo_name
relay helo
milter (OpenDKIM)
filter ... proc-exec (переписывается)
Минимальный smtpd.conf для виртуального хостинга
Ниже — полный рабочий конфиг реального виртуального хостинга: несколько доменов, ящики в Dovecot, submission на 587 и 465, DKIM. Это весь маршрутизирующий слой сервера, целиком, без вырезанных «для краткости» кусков.
nginx
# /etc/smtpd.conf
pki mail.example.com cert "/etc/ssl/mail.example.com.fullchain.pem"
pki mail.example.com key "/etc/ssl/private/mail.example.com.key"
table domains file:/etc/mail/domains
table virtuals file:/etc/mail/virtuals
table credentials file:/etc/mail/credentials
filter "dkimsign" proc-exec \
"filter-dkimsign -d example.com -s mail -k /etc/mail/dkim/mail.key"
listen on eth0 port 25 tls pki mail.example.com hostname mail.example.com
listen on eth0 port 587 tls-require pki mail.example.com \
auth <credentials> hostname mail.example.com filter "dkimsign"
listen on eth0 port 465 smtps pki mail.example.com \
auth <credentials> hostname mail.example.com filter "dkimsign"
action "deliver" lmtp "/run/dovecot/lmtp" rcpt-to virtual <virtuals>
action "relay" relay helo mail.example.com
match from any for domain <domains> action "deliver"
match auth from any for any action "relay"
match from local for any action "relay"
Разберём порядок match, потому что порядок здесь — это логика. Первое правило ловит входящую почту на ваши домены и кладёт её в deliver. Второе разрешает релей только аутентифицированным клиентам (ваши пользователи через submission) — это ваша защита от open relay. Третье пропускает локально сгенерированную почту (крон, systemd, mail). Всё, что не подошло ни под одно правило, отвергается по умолчанию. Открытого релея не бывает случайно — его нужно явно разрешить, и auth from any тут единственная дверь.
DKIM-фильтр висит только на submission-портах (587 и 465): подписывается ваша исходящая почта. Порт 25 принимает чужие письма из интернета — подписывать их своим ключом нельзя, поэтому там фильтра подписи нет.
Таблицы вместо бинарных .db-карт
table domains file:/etc/mail/domains означает буквально: читай обычный текстовый файл. Никакого postmap, никакого makemap. Поправили файл — изменения видны при следующем обращении (для устойчивости после массовых правок делайте smtpctl update table или reload, но пересборки бинарника не существует в принципе).
Хеш для credentials генерируется штатным инструментом, а не сторонним doveadm:
bash
smtpctl encrypt 'СуперСекретныйПароль'
# → $2b$10$Q8v... (bcrypt, вставляем в credentials после двоеточия)
Если данные живут в БД или LDAP, file: меняется на бэкенд из пакета opensmtpd-extras: table-postgres, table-mysql, table-sqlite, table-ldap. Они подключаются через proc-exec, но формат правил в smtpd.conf не меняется — источник данных абстрагирован от логики маршрутизации.
Доставка в Dovecot и submission на 587
Интеграция с Dovecot остаётся ровно такой, какой была при Postfix. action "deliver" lmtp "/run/dovecot/lmtp" rcpt-to virtual <virtuals> передаёт письмо по тому же LMTP-сокету. Таблица virtuals маппит внешний адрес на единого доставочного пользователя vmail (uid/gid 5000), а Dovecot сам раскладывает по Maildir в /var/mail/vhosts/<domain>/<user>/. Флаг rcpt-to заставляет OpenSMTPD передавать в LMTP исходный RCPT — Dovecot должен знать, кому доставлять.
Submission — второй listen с tls-require (STARTTLS обязателен, без него аутентификация запрещена) и auth <credentials>. Порт 465 (smtps) добавлен для legacy-клиентов, которые ждут TLS с первого байта. Оба привязаны к тому же filter "dkimsign", чтобы исходящая почта уходила подписанной.
DKIM, спам и фильтры — честный разбор гэпа
Здесь заканчиваются учебные примеры и начинается честный разговор. У OpenSMTPD нет встроенного DKIM и нет milter-совместимости с OpenDKIM. Ваша цепочка милтеров из Postfix не переносится — она переписывается.
Решение — filter API через proc-exec. DKIM-подпись ставит filter-dkimsign (форк OpenBSD, есть в пакетах большинства дистрибутивов), привязанный к submission-портам:
Спам и greylisting закрывает filter-rspamd — тонкий мост к демону Rspamd, который у вас, скорее всего, уже стоит. Подключается так же: отдельный filter и ссылка на него в listen on ... port 25, где как раз и приходит почта из интернета.
Что при этом не меняется — DNS. DKIM-запись mail._domainkey.example.com, v=spf1 mx -all, _dmarc.example.com v=DMARC1; p=quarantine описывают домен, а не MTA. Селектор и ключ те же, что были у OpenDKIM — переиспользуйте существующий приватный ключ, и валидаторы ничего не заметят.
Переключение и проверка без даунтайма
Синтаксис проверяется до запуска — аналог postfix check:
bash
smtpd -n # парсинг конфига, без старта демона
Postfix и OpenSMTPD не конфликтуют по конфигам, поэтому смоук-тест можно гонять параллельно: поднять OpenSMTPD на нестандартном порту, прогнать swaks, убедиться, что письмо легло в Maildir, и только потом переключать боевые порты.
bash
swaks --to [email protected] --server localhost:25
swaks --to [email protected] --server localhost:587 \
--auth --auth-user [email protected]
smtpctl show queue # что в очереди
smtpctl show stats # счётчики
smtpctl show envelope <id> # конверт конкретного письма
smtpctl schedule all # форсировать доставку из очереди
Лог — в syslog, facility mail. Момент переключения: systemctl stop postfix, поднять smtpd на 25/465/587. MX, A/AAAA и PTR в DNS не трогаем — они указывают на хост, а не на демон. Откат занимает те же 30 секунд в обратную сторону, потому что конфиги не пересекаются.
[ ] Перенёс домены/ящики/алиасы в текстовые таблицы domains, virtuals.
[ ] Прогнал smtpctl encrypt для всех submission-паролей, собрал credentials.
[ ] Скопировал существующие cert/key в pki (пути можно оставить прежними).
[ ] Настроил filter-dkimsign на старый селектор и ключ (только 587/465); filter-rspamd на :25 для спама.
[ ] smtpd -n проходит без ошибок.
[ ] swaks на :25 → письмо в /var/mail/vhosts/.../ через LMTP.
[ ] swaks на :587 с --auth → релей работает только с аутентификацией.
[ ] PTR совпадает с hostname в listen (иначе Gmail будет резать).
[ ] Лог чист от reject; очередь пуста.
[ ] Postfix остановлен и systemctl mask postfix, чтобы не поднялся после reboot.
Смысл всей этой затеи — не в аскетизме ради аскетизма. Когда весь маршрутизатор сервера помещается на один экран, код-ревью почтовой инфраструктуры занимает минуту, а не археологические раскопки по master.cf в поисках строки, которую кто-то поправил три релиза назад. Конфиг, который целиком виден глазом, нельзя сломать в невидимой строке.