Exim с виртуальными доменами в MySQL: router, transport и аутентификация без Unix-аккаунтов
Как заставить один Exim обслуживать десятки доменов и сотни ящиков, когда источник истины — три таблицы MySQL, а не /etc/passwd. Роутеры в правильном порядке, транспорт через Dovecot LMTP, две схемы SMTP-аутентификации и команды отладки, которые экономят часы.
EvilMail Team9 июля 2026 г.12 мин чтения
Почему виртуальные пользователи, а не системные аккаунты
Первый инстинкт новичка — на каждый ящик делать useradd. На десяти адресах работает, на трёхстах превращается в тупик: uid-коллизии, /home раздувается и лезет в бэкапы, права путаются, а отдать управление ящиками в веб-панель нельзя вообще — панель не имеет права дёргать useradd от рута. Плюс каждый системный аккаунт — это ещё и потенциальный shell-логин, лишняя поверхность атаки.
Виртуальная модель убирает всё это. Все ящики физически принадлежат одному системному пользователю vmail (uid/gid 5000), а «кто есть кто» живёт в MySQL. Добавить домен — это INSERT, а не команда на сервере.
Дальше важно не путать три роли, которые Exim играет для входящей и исходящей почты. Router решает *куда* маршрутизировать адрес (локальный ящик, алиас или удалённый релей). Transport решает *как* физически доставить (LMTP-сокет, appendfile, исходящий SMTP).
Exim + виртуальные домены в MySQL: настройка router, transport и SMTP-аутентификации — EvilMail Blog
Authenticator
решает *кто* имеет право отправлять через нас наружу. Роутеры, транспорты и аутентификаторы — три независимые секции конфига. И именно порядок роутеров и экранирование SQL ломают девять из десяти инсталляций.
Схема данных: три таблицы, которые решают всё
Вся модель — три таблицы. Домены, ящики, алиасы. Ничего лишнего.
sql
CREATE TABLE virtual_domains (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
) ENGINE=InnoDB;
CREATE TABLE virtual_users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
domain_id INT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE virtual_aliases (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
domain_id INT UNSIGNED NOT NULL,
source VARCHAR(255) NOT NULL,
destination VARCHAR(255) NOT NULL,
FOREIGN KEY (domain_id) REFERENCES virtual_domains(id) ON DELETE CASCADE
) ENGINE=InnoDB;
UNIQUE на email и на source — не косметика, а защита от двойной доставки: без него один и тот же адрес в двух строках даст непредсказуемый результат lookup'а.
Пароль храним в формате, совместимом с Dovecot, а не в открытом виде. Генерируем так:
bash
doveadm pw -s SHA512-CRYPT
# Enter new password: ...
# {SHA512-CRYPT}$6$rounds=5000$X.../...
В колонке password лежит строка целиком, с префиксом {SHA512-CRYPT}. Одно жёсткое правило: не смешивайте схемы в одной колонке. Если половина ящиков в SHA512-CRYPT, а половина в PLAIN — рано или поздно кто-то напишет аутентификатор, который проверит не то. Одна колонка — одна схема.
Подключаем Exim к MySQL
Сначала убедитесь, что драйвер MySQL вообще вкомпилирован в бинарник. На Debian это пакет exim4-daemon-heavy, лёгкий exim4-daemon-light собран без lookup-драйверов:
bash
exim -bV | grep -i mysql
# Lookups (built-in): ... mysql ...
Если mysql в выводе нет — никакие SQL-роутеры не заработают, меняйте пакет. Дальше в главном конфиге объявляем подключение:
Три вещи здесь неочевидны. Первое — hide. Без него пароль засветится в выводе exim -bP любому, кто может запустить бинарник; с hide вместо значения покажется звёздочка. Второе — сокет (/run/mysqld/mysqld.sock) вместо TCP: на локальном MySQL это быстрее и не расходует TCP-порты под нагрузкой. Третье, и главное с точки зрения безопасности, — mailuser должен быть отдельным read-only пользователем:
sql
CREATE USER 'mailuser'@'localhost' IDENTIFIED BY 'your_strong_db_password';
GRANT SELECT ON mailserver.* TO 'mailuser'@'localhost';
Только SELECT. Exim'у никогда не нужно писать в эти таблицы, а скомпрометированный конфиг с правом одного лишь SELECT не даст злоумышленнику дропнуть базу.
Теперь ключевой момент, который отделяет рабочий сервер от дырявого. Любой пользовательский ввод, попадающий в SQL, оборачивается в ${quote_mysql:...}. Адрес отправителя приходит из сети и полностью подконтролен атакующему — без экранирования это классическая SQL-инъекция через MAIL FROM. Список доменов:
domainlist mysql_domains = mysql;SELECT name FROM virtual_domains \
WHERE name='${quote_mysql:$domain}'
${quote_mysql:$domain} экранирует кавычки и спецсимволы ровно так, как ждёт MySQL. Пропустите его хоть в одном lookup — и вся остальная защита теряет смысл.
Роутеры: домены, ящики, алиасы — в правильном порядке
Exim применяет роутеры сверху вниз, и первый подходящий выигрывает. Поэтому алиасы всегда идут раньше ящиков. Логика простая: support@ может быть алиасом на boss@, и мы обязаны раскрыть его *до* попытки положить письмо в несуществующий ящик support.
virtual_alias:
driver = redirect
domains = +mysql_domains
data = ${lookup mysql{SELECT destination FROM virtual_aliases \
WHERE source='${quote_mysql:$local_part@$domain}'}}
redirect_router = virtual_alias
allow_defer
allow_fail
virtual_user:
driver = accept
domains = +mysql_domains
local_parts = ${lookup mysql{SELECT SUBSTRING_INDEX(email,'@',1) \
FROM virtual_users WHERE email='${quote_mysql:$local_part@$domain}'}}
transport = dovecot_lmtp
Разберём тонкие места. Если lookup алиаса вернул пусто, data пустая — роутер redirect тихо declines и адрес уходит дальше, к ящику. redirect_router = virtual_alias разрешает цепочки алиасов (алиас на алиас), но петлю a→b→a Exim ловит сам по счётчику Received: — цикл упрётся в лимит и вернёт defer, а не повесит демон. allow_defer и allow_fail разрешают destination'у содержать управляющие директивы вроде :fail:.
В роутере ящика обратите внимание на SUBSTRING_INDEX(email,'@',1). Наивный вариант — сравнивать $local_part напрямую, но в таблице email хранится целиком. Мы делаем наоборот: ищем строку по полному адресу $local_part@$domain, а MySQL возвращает голый local part, который Exim сверяет с local_parts. Совпало — роутер accept, не совпало — тихий decline, и письмо уходит следующему роутеру (которого нет) → 550.
Проверяем маршрутизацию, не отправляя ни одного письма:
Видите router = virtual_user — маршрут найден. Видите is undeliverable: Unrouteable address — либо домена нет в virtual_domains, либо ящика нет в virtual_users, либо забыли quote_mysql и SQL молча вернул пусто.
Транспорт: доставка через Dovecot LMTP
Роутер решил, что ящик существует. Теперь транспорт кладёт письмо на диск. Здесь два варианта, и я почти всегда выбираю первый.
Почему LMTP, а не прямая запись в Maildir через appendfile? Потому что Dovecot остаётся единственным владельцем хранилища. Он сам пишет Maildir, применяет Sieve-фильтры, считает квоты, обновляет индексы поиска. Если Exim будет писать файлы мимо Dovecot, индексы разъедутся, doveadm quota покажет чушь, а dsync-репликация начнёт конфликтовать. batch_max = 200 позволяет за одну LMTP-сессию доставить письмо 200 локальным получателям — важно для рассылок на большие алиасы.
Если Dovecot по каким-то причинам не используется, вот прямой вариант:
virtual_delivery:
driver = appendfile
maildir_format
directory = /var/mail/vhosts/$domain/$local_part
create_directory
directory_mode = 0700
user = vmail
group = vmail
Все файлы принадлежат vmail:vmail (uid/gid 5000), путь предсказуем. Но повторюсь: как только появляются квоты, Sieve или репликация — возвращайтесь на LMTP.
Исходящий транспорт подписывает почту DKIM прямо на выходе:
Приватный ключ лежит по домену отправителя, селектор mail — тот же, что в DNS. Файл .pem должен читаться пользователем, под которым бежит Exim, иначе подпись молча не проставится, и получатель увидит dkim=none.
SMTP-аутентификация без Unix-аккаунтов
Клиенту нужно отправлять почту через порт 587, а значит — аутентифицироваться. Unix-аккаунтов у нас нет, пароли в MySQL. Есть два пути.
Путь первый — автономный MySQL-аутентификатор. Exim сам достаёт хеш из базы и сверяет:
crypteq разбирает модульный формат crypt(3): по префиксу $6$ он понимает, что это SHA-512, и применяет ту же соль через системный crypt(). $auth1 — логин (полный email), $auth2 — присланный пароль. {$value}fail означает: если lookup ничего не нашёл, условие проваливается, а не сравнивается с пустотой. Учтите нюанс: для crypteq в колонке должен лежать голый $6$rounds=5000$... без обёртки {SHA512-CRYPT} (иначе crypteq примет {SHA512-CRYPT} за имя своей схемы), поэтому либо держите отдельное поле, либо — что чище — используйте второй путь.
Путь второй, рекомендуемый — делегировать Dovecot SASL. Dovecot уже проверяет пароли для IMAP/POP. Пусть он же проверяет их для SMTP — один источник истины, ноль дублирования логики паролей:
Exim прокидывает креды в Dovecot через auth-сокет и доверяет его вердикту. Меняете схему хеширования, добавляете app-пароли, ротацию — всё в одном месте, Exim трогать не надо. Моя позиция однозначна: верификация пароля должна жить в одном месте (Dovecot SASL), а MySQL-аутентификатор в Exim — это запасной, автономный вариант для случаев, когда Dovecot недоступен.
И в обоих случаях — server_advertise_condition = ${if def:tls_in_cipher}. Это не опция, это правило. Exim вообще не анонсирует AUTH LOGIN/PLAIN, пока сессия не защищена TLS. LOGIN и PLAIN передают пароль почти открытым текстом (base64 — это не шифрование). Отдать их в незашифрованном канале — значит раздать пароли всем, кто слушает трафик. Submission на 587 с обязательным STARTTLS, релей наружу — только после успешной аутентификации.
Диагностика: команды, которые экономят часы
Когда что-то не доставляется, не гадайте — спросите Exim напрямую.
exim -d+lookup+expand -bt — самый недооценённый. Он показывает *точный* SQL после подстановки quote_mysql и результат lookup'а. Видите в выводе пустую строку вместо запроса — переменная не раскрылась. Видите SQL, но пустой результат — данных нет в таблице или адрес не совпал по регистру.
Типовые грабли, на которые уходят часы:
Пустой lookup = тихий `decline`. Роутер не падает с ошибкой, он молча пропускает адрес. Нет ящика → следующий роутер → неявный 550. В логах не будет «ошибки MySQL», будет просто «Unrouteable».
Забыли `quote_mysql`. В лучшем случае адрес с кавычкой ломает запрос, в худшем — инъекция.
`hide` не в той секции. Пароль MySQL светится в exim -bP.
LMTP-сокет с неверными правами. Exim не может писать в /run/dovecot/lmtp — доставка виснет в defer. Сокет должен быть доступен группе, под которой бежит Exim.
MySQL `max_connections` исчерпан на пике. Каждый lookup — коннект. На всплеске входящей почты пул кончается, и Exim начинает откладывать письма. Держите read-only пользователю разумный лимит соединений.
Чек-лист перед запуском в прод
MX, A и PTR согласованы: MX 10 mail.example.com, A mail → 203.0.113.10, PTR 203.0.113.10 → mail.example.com
SPF опубликован: v=spf1 mx ~all
DKIM-селектор в DNS (mail._domainkey.example.com TXT "v=DKIM1; k=rsa; p=..."), приватный ключ .pem читается пользователем Exim
DMARC хотя бы на карантин: _dmarc.example.com TXT "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
MySQL-пользователь имеет только GRANT SELECT
Все без исключения lookup'ы обёрнуты в ${quote_mysql:...}
TLS-сертификат валиден, server_advertise_condition включён — AUTH только по TLS
Пароль проходит проверку: doveadm pw -t '{SHA512-CRYPT}$6$...'
/var/log/exim4/mainlog, rejectlog, paniclog — без неожиданных reject'ов
Квоты через doveadm quota работают, бэкап Maildir настроен и проверен восстановлением
Три таблицы, три секции конфига, один системный пользователь на всё хозяйство. Как только конвейер router → transport → authenticator выстроен в правильном порядке и каждый SQL проходит через quote_mysql, добавление сотого домена — это одна строка INSERT, а не вечер в консоли.