Свой почтовый сервер в 2026 году — уже не хобби, а вынужденная мера: бесплатную почту для своего домена похоронили все крупные провайдеры. При этом сам стек пережил встряску — в Dovecot 2.4 полностью переписали синтаксис конфига, и всё, что я и другие писали про почтовые серверы до 2025 года, годится разве что в музей: старый конфиг 2.3 демон просто не запустит.
Поэтому собираю почтовый сервер с нуля на Ubuntu 26.04 LTS и фиксирую весь процесс: Postfix 3.10.6 как MTA, Dovecot 2.4.2 для IMAP/POP3 и доставки по LMTP, PostfixAdmin как панель управления ящиками, Roundcube в качестве веб-интерфейса, OpenDKIM для подписи писем и Fail2Ban для отстрела переборщиков паролей. Веб-часть у меня на nginx и PHP 8.5, база — Percona Server for MySQL 8.4 LTS. Всё на одном сервере, конфиги целиком, с объяснением, почему именно так. В конце — опциональные блоки: несколько доменов, отправка через внешний релей, грейлистинг, SpamAssassin, phpMyAdmin, ограничение доступа к панелям по IP и закрытие периметра (SSH + файрвол).
Содержание
По тексту использую документационные значения — подставляйте свои:
Ящики делаю вида
Любую настройку почты я начинаю не с конфигов, а с проверки, что с этого сервера вообще можно слать письма — многие провайдеры режут исходящий TCP/25 по умолчанию:
Второе — репутация IP. Прогоняю адрес по чёрным спискам (hetrixtools.com → Blacklist Monitor, mxtoolbox.com). Если хостер выдал адрес из-под бывшего спамера, вы потратите недели на выяснение, почему «всё настроено, а письма не доходят». Дешевле сразу попросить другой IP — я так и делаю.
Без правильного DNS сервер работать будет, но письма полетят в спам. Минимум, который я завожу до начала настройки:
SPF, DKIM и DMARC добавлю позже, когда будет что в них писать.
Обязательный пункт — обратная зона (PTR): IP-адресу должно соответствовать имя
через bind или curl
для проверки через утилиту bind
ставлю:
и проверяю
или через curl к публичному DNS API (например, Google DNS):
Идеальная картина: MX указывает на имя, имя резолвится в IP, IP резолвится обратно в то же имя. Спам-фильтры проверяют именно это тройное соответствие. Без PTR часть серверов вашу почту не примет вовсе — вы для них спамер по умолчанию.
Время обязано синхронизироваться — для почты это критично: DKIM-подписи и заголовки с кривыми датами вызывают у принимающих серверов вопросы. В
Отключаю ipv6, ниже настройки для ipv4 который и будет работать
тут нужно добавить две строки
пример готового конфига
применяю безопасно
подтверждаю
на уровне ядра тоже
и
После перезагрузки убеждаюсь, что v6 действительно мёртв:
Полумеры через sysctl не использую сознательно: disable_ipv6 снимает адреса, но демоны, которые биндятся на wildcard
Домены, ящики и алиасы живут в базе. В качестве СУБД я беру Percona Server for MySQL 8.4 LTS — бинарно совместимый drop-in вариант MySQL с расширенной диагностикой из коробки (подробный slow log, статистика по пользователям и индексам) и родным XtraBackup для горячих бэкапов.
В штатных репозиториях Ubuntu Percona отсутствует, ставится через percona-release:
Переменная PERCONA_TELEMETRY_DISABLE=1 отключает телеметрию, которую Percona с некоторых пор кладёт в пакеты, — статистика «домой» с моего прода никому не нужна.
Инсталлятор через debconf спросит пароль root (про «Legacy» — ниже). Дальше стандартная зачистка:
Кратко о шагах:
VALIDATE PASSWORD COMPONENT - No
Change the password for root - задать пароль root
Remove anonymous users? - Y
Disallow root login remotely? - Y
Remove test database and access to it? - Y
Reload privilege tables now? - Y
В линейке MySQL 8.4 плагин mysql_native_password по умолчанию выключен, всё аутентифицируется через caching_sha2_password. Для веб-части это не проблема: PHP умеет sha2 со времён 7.4, да и ходят PostfixAdmin с Roundcube в базу по unix-сокету. Проблема ровно в одном месте: Postfix ходит в базу по TCP 127.0.0.1 (из-за chroot — объясню в его разделе), собран он с клиентской libmysqlclient24 из состава mysql-8.4, а полная аутентификация caching_sha2 без TLS требует RSA-обмена ключами — и запросить у сервера публичный ключ через карты dict_mysql просто негде, такой опции в .cf-файлах нет. После первого успешного входа mysqld кэширует хэш и пускает по быстрому пути, но кэш живёт в памяти и обнуляется рестартом базы. Итог — отложенный отказ вида «работало месяц, а после планового рестарта mysqld почта встала». Дебажить такое на почтовике — удовольствие ниже среднего.
Поэтому одну служебную учётку — ту, которой пользуются Postfix и Dovecot, — я осознанно завожу на mysql_native_password: она ходит только по loopback и сокету, перехватывать там нечего, а детерминированность мне дороже модного хэша. Включаю плагин в
и еще две строки для работы на localhost
пример конфига
делаю перезапуск
Граблина от Percona: пункт «Legacy (Native Password)» в debconf-диалоге установщика сам по себе плагин НЕ включает — строку в конфиг всё равно писать руками, иначе на первом же CREATE USER получите «Plugin 'mysql_native_password' is not loaded». Документация об этом честно предупреждает, но кто ж её читает.
Создаю базу и пользователя — и для localhost, и для 127.0.0.1 (зачем Postfix'у TCP — ниже):
далее
закончив выхожу
где y47Sa3KO - мой "сложный" пароль
Учётки Roundcube и phpMyAdmin дальше по тексту остаются на дефолтном caching_sha2 — они ходят по сокету через PHP, там всё штатно. Честная цена компромисса: в MySQL 9.x native-плагин выпилен совсем, при переезде на следующий LTS учётку postfix придётся мигрировать одной командой ALTER USER — переживу. Пуристы могут вместо этого поднять TLS до базы на loopback — я в этом виде спорта смысла не вижу.
Кому нужен phpMyAdmin для заглядывания в базу — ниже в статье есть отдельный опциональный раздел про него, с установкой руками и доступом только с разрешённых IP.
В Ubuntu 26.04 дефолтный PHP — 8.5, отсюда все пути вида
Пулы php-fpm перекрашивать не нужно: nginx и php-fpm из коробки живут под
Быстрый фикс
Сертификат получаю сразу, до настройки Postfix и Dovecot — чтобы в их конфигах с первого дня стояли боевые пути, а не самоподписанный мусор, который потом все забывают поменять. Один сертификат на оба имени.
Удаляем дефолтный конфиг
Создаем новый временный конфиг
ставим certbot для получения и обновления сертов
и получаем серты для двух доменов
кратко шаги:
- Enter email address or hit Enter to skip. - наш ящик для писем
- Please read the Terms of Service at - соглашаемся с условиями - Y
- Would you be willing, once your first certificate is successfully issued - тут вопрос про получение уведомлений, новостей, Y или N на выбор
Успех выглядит так:
Сертификат ляжет в
Для закрытых контуров без Let's Encrypt — самоподписанный на 10 лет одной командой, пути в конфигах ниже поменяете на свои:
Панель управления ящиками. В репозитории Ubuntu лежит старая ветка, поэтому беру релиз с GitHub — на момент написания актуальна 4.0.5, сверьтесь с релизами:
Мой
По параметрам:
Установщик скачает composer и подтянет зависимости:
Теперь nginx-хост для панели,
Ставим и запускаем утилиту для создания пароля для пользователя padmin - его можно задать своим
далее конфиг
делаю симлинк, проверяю и перезапускаю
Проверяю права на /tmp — mysqld под капотом ALTER TABLE пишет туда временные таблицы:
На чистой системе так и есть; если sticky-бит или права на запись потерялись (чужие «харденинги», кривая миграция) — возвращаю
Иду на
ввожу свой логин padmin и пароль и вижу страницу
Все проверки должны быть зелёными. В форме Generate setup_password генерирую хэш ввожу пароль например g6dDQC31 (обязательно задай свой и не копируй мой) и нажимаю generate, вставляю его в
nano /var/www/postfixadmin/config.local.php
обновляю страницу и внизу создаю администратора панели.
Setup password это пароль который задан в качестве хэша g6dDQC31 пишу его в это поле
Админ root@hosterum.ru
пароль для root@hosterum.ru у меня в этом поле zP3N12wY (у тебя свой)
пароль еще раз zP3N12wY
при успехе должен увидеть подтверждение по добавленному админу
Вопросы дебконфа неважны (Internet Site, имя как в DNS) — конфиг всё равно пишу свой. Сохраняю оригинал и заменяю
затру оригинал
следом добавляю свой
Что здесь принципиально и почему я сделал именно так:
Во всех файлах —
В файлах лежит пароль от базы — прикрываю:
Смартфоны и Outlook хотят submission (587, STARTTLS) и smtps (465, TLS-wrapper). Добавляю в
Добавляю в конец
Postfix пока не запускаю — он завязан на сокеты Dovecot, которых ещё нет.
Если нужен служебный архив входящей/исходящей (аудит, «я этого письма не получал»), добавьте в /etc/postfix/main.cf:
Содержимое файлов — по строке на домен, ящики создать в PostfixAdmin:
Учтите: почтовая база начнёт расти с двойной скоростью, эти ящики надо регулярно чистить.
Сценарий: 25-й порт наружу закрыт намертво, или IP с несмываемой репутацией, или хостер даёт свой SMTP-релей и требует слать через него. Тогда вся исходящая почта уходит не напрямую по MX получателей, а через smarthost:
Квадратные скобки вокруг имени отключают MX-lookup — подключаемся именно к этому хосту. Учётка релея:
Три момента, о которых забывают. Первый: принимающая сторона видит IP релея, а не ваш, поэтому SPF-запись обязана разрешать релей — обычно это
Обратная задача — сам сервер как релей (резервный MX для чужого домена) — у нас уже наполовину готова: карта relay_domains подключена, в PostfixAdmin у домена есть галка «резервный MX». Поставили галку, добавили MX 20 в DNS чужого домена — и сервер принимает и придерживает почту, пока основной лежит. Для прода к этому обязательно добавляется
Главная причина, по которой умерли все старые статьи. Краткий словарь для тех, кто, как и я, переезжает с 2.3:
Файл
Конфигурация по умолчанию размазана по
Комментарии к спорным местам:
Проверка конфига перед стартом —
Ротация логов,
Postfix пишет в syslog. В минимальной установке 26.04 текстового
Логинюсь через /login.php (https://mail.hosterum.ru/login.php), добавляю домен (Список доменов → Новый домен) и первый ящик (Обзор → Создать ящик).
Smoke-тест SQL-карт — до всяких писем:
Пусто — значит, Postfix не достучался до базы или ящика нет. Заодно это проверка связки libmysqlclient + Percona + mysql_native_password: если тут ошибка аутентификации — возвращайтесь в раздел про базу, а не копайте почтовые конфиги.
Дальше шлю письмо снаружи (с gmail) на admin@hosterum.ru и смотрю
Письмо физически лежит в
Проверяю клиентом: IMAP — mail.hosterum.ru, порт 993, SSL/TLS; SMTP — порт 465 (SSL) или 587 (STARTTLS); логин — полный адрес ящика, не короткое имя. Автоопределение в Thunderbird любит угадать неправильно — проверяйте руками.
Отправить наружу можно уже сейчас, но Gmail завернёт письмо: «sender is unauthenticated, SPF/DKIM did not pass». Это ожидаемо — идём настраивать подписи. Три лога для любых разборов:
Веб-интерфейс для пользователей плюс единственный вменяемый способ управлять sieve-фильтрами и общими папками. Ветка 1.7 уже вышла. Актуальный номер сверяйте на github.
Сборка -complete уже содержит все PHP-зависимости в vendor/ — доставлять пакеты php-net-sieve и прочие, как советуют старые мануалы, не нужно. База (учётка остаётся на дефолтном caching_sha2 — PHP по сокету с ним дружит):
Правки в
nginx-хост
Две неочевидные строки в блоке static.php:
Установщик —
CREATE CONFIG → CONTINUE → кнопка Initialize database. После установки — обязательно:
Логинюсь полным адресом ящика. Ошибки Roundcube — в
ротация логов
Проверка без реальной ротации
Sieve выполняет сортировку в момент доставки на сервере — правило работает во всех клиентах сразу. Конфиг плагина:
Последний параметр включает автоответчик: в интерфейсе появится Настройки → Вне офиса — под капотом это то же sieve-правило. Обычные фильтры — Настройки → Фильтры; папку назначения создайте заранее в Настройки → Папки. На диске правила лежат в
Плагин acl я уже включил с обеих сторон. Пользователь создаёт папку (Настройки → Папки), открывает её свойства и раздаёт права на чтение/запись конкретным ящикам или всему домену. У получателя папка появляется в дереве Shared. Удобно для общего ящика «на вход»: в отличие от алиаса на десять человек, письмо хранится в одном экземпляре, а не размножается по всем ящикам.
DKIM — цифровая подпись исходящих писем ключом домена; открытая часть публикуется в DNS, принимающий сервер сверяет. От спама не защищает (спамер подпишет свой домен так же), но однозначно подтверждает, что письмо от вас, — и без неё Gmail с Яндексом сегодня почту просто не принимают.
Селектор
Права:
Милтер слушаю по TCP на loopback, а не через unix-сокет — smtpd в chroot'е, и с сокетом пришлось бы возиться с путями внутри /var/spool/postfix. Две граблины Ubuntu-пакета:
Подключаю милтер к Postfix — в конец
Частый вопрос: подпишутся ли письма пользователей, которые сидят по SASL с внешних IP через 587-й порт? Подпишутся, и никакие списки InternalHosts с офисными сетями не нужны. OpenDKIM подписывает письмо, если выполнено хотя бы одно из условий: клиент из InternalHosts (127.0.0.1 по умолчанию — покрывает Roundcube) или клиент прошёл аутентификацию — Postfix передаёт милтеру макрос {auth_type} в дефолтном milter_mail_macros, и любой успешный SASL-логин включает подпись.
Открытый ключ — в DNS. Содержимое
Создаю TXT-запись
Тест: письмо на gmail → «Показать оригинал» → DKIM: PASS. В mail.log при этом появляется строка
SPF — TXT-запись, перечисляющая, кому разрешено слать почту от имени домена:
Вот так это выглядит:
Важно: SPF-запись на домене должна быть ровно одна. Две TXT с v=spf1 — это не «двойное разрешение», а PermError по RFC 7208: принимающая сторона отбрасывает обе, и SPF перестаёт работать вообще. Если панель DNS предупреждает, что запись уже существует, — правьте существующую, а не добавляйте вторую.
DMARC — политика для принимающей стороны: что делать с письмами, не прошедшими SPF/DKIM. На сервере настраивать нечего, только DNS:
первый вариант
Начать можно с
второй вариант
Вся конфигурация выше изначально мультидоменная — SQL-запросы Postfix и Dovecot работают с любым количеством доменов, конфиги при добавлении второго домена не трогаются вообще. Порядок действий для домена filehost.su:
1. PostfixAdmin. Список доменов → Новый домен → filehost.su. Ящики создаются там же. Всё, база знает о домене — Postfix и Dovecot тоже.
2. DNS второго домена. MX указывает на существующее имя сервера — новой A-записи не нужно:
3. DKIM. Единственное место, где нужны руки: ключ на каждый домен свой. Генерирую и дописываю по строке в обе таблицы:
Плюс TXT-запись
Так же добавляю
4. Чего делать НЕ нужно. Новый сертификат не нужен: клиенты всех доменов подключаются к одному и тому же
Нюанс: с несколькими доменами параметр
Отдельная фича PostfixAdmin — домены-псевдонимы (alias domains): почта на
При добавлении нового ящика в thunderbird может потребоваться ручная настройка: в поля «Имя сервера» вписывается FQDN сервера (
Ограничения в
Формат — значение и действие:
(и после каждого изменения — тоже). Классы вставляются в нужные restrictions сразу после permit_sasl_authenticated, белые — раньше чёрных:
Жёсткие проверки — включать с открытыми глазами:
На время обкатки любых новых ограничений я ставлю
Против осмысленного спама (а не ботов) штатных средств мало. Мой основной рецепт — Proxmox Mail Gateway перед сервером: бесплатный, фильтры из коробки, управление через веб. Но если вторая машина — роскошь, ниже два опциональных рецепта на этом же сервере: грейлистинг и SpamAssassin.
Идея грейлистинга: первому письму от незнакомой тройки «IP + отправитель + получатель» отвечаем временной ошибкой 450. Честный сервер повторит доставку через несколько минут — письмо пройдёт и тройка попадёт в белый список навсегда. Спам-боты в массе своей не ретраят. Режет спам на десятки процентов бесплатно и почти без CPU.
Демон слушает 127.0.0.1:10023. Дефолтная задержка 300 секунд избыточна, в
по умолчанию заменим
на
Подключение — последним пунктом в smtpd_recipient_restrictions, чтобы дешёвые reject'ы отработали раньше и до postgrey доезжали только письма, которые мы в принципе готовы принять:
Пакет несёт приличный белый список крупных отправителей (
Честно про цену вопроса: первое письмо от любого нового отправителя опаздывает — обычно на 1–15 минут, у криво настроенных серверов на часы. Хуже всего с одноразовыми письмами вроде кодов подтверждения: часть сервисов шлёт их с ротацией IP и не ретраит — код может не дойти вовсе. Именно из-за этого я грейлистинг включаю не везде: для корпоративной переписки — да, для ящиков, на которые регистрируются в сервисах, — подумайте дважды.
Контентный фильтр для тех, кому мало отсева на уровне SMTP. Подключаю через spamass-milter — письмо сканируется до постановки в очередь, без городьбы с content_filter и повторной инъекцией:
Минимальный
В
Два ключа здесь принципиальны:
(в старых релизах юнит SpamAssassin назывался spamassassin — проверяется через
Подключаю к Postfix: в цепочку smtpd-милтеров — после OpenDKIM (сначала проверяем чужую подпись, потом считаем баллы), а в non_smtpd не добавляю — локально сгенерированную почту сканировать незачем:
Осталось автоматически складывать размеченное в Junk. Глобальный sieve-скрипт, отрабатывающий до пользовательских правил:
И блок в dovecot.conf рядом с существующим sieve_script
и рестарт postfix и dovecot):
Цена вопроса: SpamAssassin — perl-демон, в памяти держит 200–300 МБ. На VPS с гигабайтом RAM я бы его не заводил — либо PMG на отдельной машине, либо посмотрите на rspamd, но это тема для отдельной статьи. Дообучение байеса (
Выше я говорил, что в базу хожу консольным клиентом — это правда, но иногда phpMyAdmin экономит время: глянуть структуру таблиц PostfixAdmin, поправить опечатку в алиасе, выгрузить дамп одной кнопкой. Пакетный phpmyadmin из репозитория тянет Apache зависимостями и кладёт конфиги куда попало, поэтому ставлю руками с официального сайта — на момент написания актуальна 5.2.3 (важно: именно в ней закрыты депрекейты PHP 8.5, на младших релизах ветки будете разгребать warning'и), сверьтесь с downloads.
Конфиг
blowfish_secret — ровно 32 символа, генерируется так:
host = localhost — PHP пойдёт по unix-сокету, TCP тут не нужен. Технически учётка root@localhost в форму phpMyAdmin зайдёт: соединение локальное, запрет из mysql_secure_installation касался только удалённого входа. Но давать веб-морде root я смысла не вижу — завожу отдельного пользователя с правами ровно на те базы, в которые буду заглядывать:
Отдельный поддомен под phpMyAdmin — лишняя сущность: ещё одно имя в сертификате, ещё одна DNS-запись, и всё ради инструмента, которым пользуется один человек раз в месяц. Вешаю его локейшеном /pma/ на уже существующий вхост панели (mail.hosterum.ru) — там уже есть basic auth на весь server-блок, он автоматически накроет и phpMyAdmin. Сверху добавляю второй замок: доступ только с моего IP.
В
Три неочевидных места, на которых обычно спотыкаются:
Захожу на https://mail.hosterum.ru/pma/ — сначала basic auth (padmin), затем форма phpMyAdmin (pma / пароль из CREATE USER). С любого другого IP — 403 ещё на пороге.
У кого IP динамический — деваться некуда, остаётся один basic auth (уберите строки allow/deny) либо доступ через VPN до сервера: WireGuard-интерфейс с адресом 10.0.0.1 и allow 10.0.0.0/24 — самый чистый вариант, но это тема отдельной статьи.
Через несколько часов после появления MX-записи к серверу придут боты и начнут методично перебирать пароли — sales@, admin@, info@ и далее по словарю. Fail2Ban читает логи, находит по regex строки неудачных аутентификаций и банит IP файрволом.
В Ubuntu 26.04 это ветка 1.1.x, из коробки включён только jail sshd (через jail.d/defaults-debian.conf). Свои настройки я кладу в
Баню нативными nftables-действиями: iptables на 26.04 — прослойка-переводчик поверх nftables, ходить через неё смысла нет. Пользователям ufw —
В ignoreip помимо loopback у меня внесён IP Zabbix-сервера (127.0.0.2 в примере): мониторинг стучится в почтовые порты без аутентификации, со стороны фильтров это выглядит как перебор, и без исключения вы забаните собственный мониторинг. Проходил.
Два принципиальных отличия от типовых мануалов:
1. filter = postfix[mode=auth] вместо самописного фильтра. У штатного фильтра postfix есть режимы: normal, auth, rbl, more, extra, aggressive. Строки вида
ловит именно режим auth — дефолтный normal их не видит, из-за чего половина интернета годами пишет собственные регулярки. Не надо: включите правильный режим. Хотите заодно банить мусорные коннекты (pipelining, кривой HELO) — mode=aggressive, но сначала прогоните его по своему логу.
2. port со всем почтовым набором в каждом jail'е. В классических конфигах action банит только smtp — бот, пойманный на 25-м порту, спокойно продолжает долбить 465, 993 и 995, и смысл бана стремится к нулю. Раз IP замечен в переборе — я закрываю ему почту целиком. Радикалы могут поставить в jail'ах
В типовых мануалах здесь включают джейл [dovecot] со штатным фильтром. Не тратьте время: на логах 2.4 он не ловит НИЧЕГО. Проверено на живом логе с реальным перебором — fail2ban-regex выдал 0 совпадений из 21510 строк, а бот, которого мой фильтр ниже поймал за 12 секунд, для штатного прошёл невидимкой. Причина — в 2.4 переписали формулировки: вместо «Aborted login (auth failed, ...)» демон теперь пишет «Login aborted: Connection closed (auth failed, 1 attempts in 2 secs) (auth_failed): ...», и регэкспы, писанные под 2.3, промахиваются мимо всех строк целиком. Поэтому джейла [dovecot] в моём jail.local просто нет — мёртвый груз.
Вместо него — строки auth-worker'а:
Важно:
Тонкость, о которой типовые мануалы молчат хором. Roundcube ходит в IMAP с localhost, поэтому при переборе пароля через веб-интерфейс Dovecot честно пишет rip=127.0.0.1 — а fail2ban такие находки демонстративно пропускает, в его логе появляется «Ignore 127.0.0.1 by ignoreself rule». И это правильно: ignoreself — защита от выстрела себе в ногу, бан собственного loopback положил бы разом и веб-почту, и SASL-связку Postfix↔Dovecot. Отключать его нельзя — атаку через веб надо ловить там, где виден настоящий адрес: в логе самого Roundcube.
Включаю журнал логинов — строка в
С этого момента каждый вход, удачный и нет, пишется в
это надо сразу проверить
IP в строке должен быть ваш внешний, не 127.0.0.1 — Roundcube пишет адрес HTTP-клиента, nginx у нас отдаёт его в php-fpm напрямую. Регэксп-прогон — в общем блоке проверки ниже.
Для эстетов существует второй путь — научить сам Dovecot видеть реальный адрес веб-клиента: плагин dovecot_client_ip для Roundcube передаёт IP посетителя IMAP-командой ID, а
Главная команда всего раздела. Формат лог-строк у Postfix и Dovecot имеет привычку меняться между версиями, поэтому любой фильтр я сначала прогоняю по реальному логу:
Перебор в логе есть, а совпадений ноль — фильтр не подходит под ваш формат строк; правьте regex, а не включайте вслепую. Ноль совпадений при чистом логе ничего не доказывает — сначала сгенерируйте неудачный вход (ниже в боевом тесте), потом делайте выводы. Любителям чистого journald: можно перевести postfix-jail на
Работа видна в
Имитирую перебор с посторонней машины (не с сервера — тест с внешнего IP честнее). Первый заход — SMTP, проверяет jail postfix:
Три захода — и в fail2ban.log появляется
Второй заход — IMAP, проверяет самописный dovecot-sql (с другого IP, первый уже в бане):
Три неверных пароля — в /var/log/dovecot/info.log должны появиться те самые строки
Третий заход — веб-морда: три неверных пароля в форме Roundcube (тоже с постороннего IP — свой рабочий улетит в бан вместе с вами) — и в fail2ban.log появляется
Убедиться в банах можно через
Про самобан. Три опечатки пароля — в почтовом клиенте или в веб-морде, неважно — и пользователь улетает в бан вместе со всей своей почтой на час. Либо объясните это людям заранее, либо держите unbanip под рукой. Свои админские IP можно внести в ignoreip; локальные сети пользователей — я бы не вносил: перебор из LAN тоже случается, и лучше о нём узнать от fail2ban, чем от последствий.
Basic auth на PostfixAdmin уже стоит — это обязательный минимум. Если админите с постоянного адреса, имеет смысл закрутить и его, и Roundcube жёстче. Подходы для них разные, потому что аудитория разная: в панель хожу я один, в веб-почту — все пользователи.
В
Всё: пароль спрашивается только у тех, кто пришёл с разрешённого адреса, остальным — 403. Замок на замке. Единственное «но» — на этом же вхосте по договорённости выше живёт /pma/: его собственные allow/deny внутри локейшена продолжают работать независимо, server-уровневые добавляются к ним первыми. Конфликта нет, просто помните, что теперь фильтра два.
Закрывать веб-почту по IP целиком — значит отрезать пользователей с мобильных сетей, где адрес меняется на каждом переподключении. Но бывают инсталляции «только для своих» (семья, маленький офис со статикой) — тогда та же пара строк в server {} вхоста webmail решает вопрос. Для варианта посередине — «своим свободно, из недоверенных сетей блокируем» — у nginx есть модуль geo: список задаётся вне server {}, в
И в server {} вхоста webmail — достаточно проверки на уровне server:
if на уровне server с return — один из немногих легальных способов применения if в nginx, никакой магии location-контекста тут нет. Держать список отдельным файлом удобно: добавили сеть — reload — работает, вхост не трогается.
Честно про цену: любой IP-фильтр на веб-почте означает, что пользователь из отпуска без VPN почту не откроет. Я на публичных инсталляциях этим не пользуюсь — fail2ban с задачей отстрела перебора справляется, а удобство пользователей дороже. Для корпоративного контура со своим VPN — наоборот, обязательная мера.
Почта работает, панели закрыты — остался сам сервер. Три меры: нестандартный порт SSH, запрет root-логина, файрвол со списком того, что торчит наружу. Порядок действий важен: сначала файрвол с правилом на НОВЫЙ порт, потом смена порта, потом проверка вторым сеансом — и только после этого закрытие старого. Нарушите порядок на удалённой машине — поедете в консоль хостера.
nftables я использую напрямую в fail2ban, но руками таблицы писать — на любителя; ufw поверх nftables даёт человеческий синтаксис и не конфликтует с f2b (у того свои цепочки). Внимание пользователям ufw-actions в fail2ban: если вы в разделе про f2b выбрали banaction = ufw — всё уже согласовано; если оставили мои nftables-multiport — тоже работает, цепочки f2b обрабатываются до правил ufw.
Кто не оставил POP3 в protocols у Dovecot — строки 110/995 пропускайте. 4190 наружу нужен, только если фильтры правят не через Roundcube, — иначе тоже можно не открывать. SSH-порт при желании сразу зажимается по источнику:
Смена порта — не защита от целевой атаки (nmap найдёт за минуту), а санитария: журнал перестаёт распухать от ботов, долбящих 22 по словарю, и sshd-jail в fail2ban начинает ловить только тех, кто искал осознанно. В Ubuntu 26.04 правки кладу в отдельный файл, /etc/ssh/sshd_config не трогаю — переживёт обновления пакета:
PermitRootLogin no подразумевает, что на сервере есть обычный пользователь с sudo — если работали под root, СНАЧАЛА заведите его и проверьте вход:
PasswordAuthentication yes оставляю сознательно — переезд на ключи правильнее, но это отдельное действие: сгенерировали пару, положили в ~/.ssh/authorized_keys, проверили вход по ключу — и только потом PasswordAuthentication no. В одной куче с переносом порта делать это не советую: два изменения аутентификации разом — двойной шанс отпилить сук.
и перезапускаю
Теперь — критический шаг: НЕ ЗАКРЫВАЯ текущую сессию, открываю второй терминал:
Зашло — убираю старый порт:
И последний штрих — jail sshd в fail2ban про новый порт не знает, у него в дефолтах port = ssh (22). В /etc/fail2ban/jail.local правлю секцию:
С посторонней машины:
Правильная картина: 22 — closed/filtered, 3022 и почтово-веб-набор — open, 3306 и 33060 — filtered (они и так слушают только loopback, но файрвол теперь страхует и от будущих ошибок конфигурации базы).
Затем прогоняю письмо через mail-tester.com и добиваюсь 10/10. В Gmail «Показать оригинал» должен показывать SPF: PASS, DKIM: PASS, DMARC: PASS. Попадание первых писем в спам при нулевой репутации свежего IP — норма, а не ошибка конфигурации: пара пометок «не спам» у получателей лечит это быстрее, чем любые пляски с настройками. Массовых рассылок с нового сервера не делайте — репутацию зарабатывают тишиной.
Порты, которые должны быть открыты на файрволе:
Получился полноценный почтовый сервер на актуальном стеке: Postfix 3.10 принимает и шлёт, Dovecot 2.4 хранит и отдаёт, Percona Server держит учётки, PostfixAdmin управляет, Roundcube показывает, OpenDKIM подписывает, Fail2Ban отстреливает ботов по всем портам сразу, а периметр закрыт файрволом с SSH на нестандартном порту. Второй и десятый домен добавляются за пять минут, а при необходимости конструкция расширяется релеем, грейлистингом, SpamAssassin и phpMyAdmin — соответствующие опциональные разделы выше.
Главный вывод по Dovecot 2.4: конфиг действительно переписали с нуля, но новый синтаксис логичнее старого — SQL-запросы в одном файле с остальным, переменные читаемы, версионирование конфига ловит ошибки на старте, а не в проде. Один раз переболел — дальше жить проще.
Что стоит прикрутить следом: мониторинг (очередь Postfix и срок сертификата в Zabbix), бэкап /var/mail/vhosts вместе с дампом базы (раз уж стоит Percona — XtraBackup в помощь), и — если спам станет проблемой всерьёз — Proxmox Mail Gateway перед сервером.
Поэтому собираю почтовый сервер с нуля на Ubuntu 26.04 LTS и фиксирую весь процесс: Postfix 3.10.6 как MTA, Dovecot 2.4.2 для IMAP/POP3 и доставки по LMTP, PostfixAdmin как панель управления ящиками, Roundcube в качестве веб-интерфейса, OpenDKIM для подписи писем и Fail2Ban для отстрела переборщиков паролей. Веб-часть у меня на nginx и PHP 8.5, база — Percona Server for MySQL 8.4 LTS. Всё на одном сервере, конфиги целиком, с объяснением, почему именно так. В конце — опциональные блоки: несколько доменов, отправка через внешний релей, грейлистинг, SpamAssassin, phpMyAdmin, ограничение доступа к панелям по IP и закрытие периметра (SSH + файрвол).
Содержание
DKIM
Исходные данные
По тексту использую документационные значения — подставляйте свои:
| Параметр | Значение в примерах |
|---|---|
| Почтовый домен | hosterum.ru |
| Имя сервера (FQDN) | mail.hosterum.ru |
| Веб-почта | webmail.hosterum.ru |
| Внешний IP | 192.145.97.212 |
| ОС | Ubuntu Server 26.04 LTS |
Ящики делаю вида
user@hosterum.ru, почту храню в /var/mail/vhosts в формате Maildir под системным пользователем vmail (uid/gid 1100).Проверка хостера и IP
Любую настройку почты я начинаю не с конфигов, а с проверки, что с этого сервера вообще можно слать письма — многие провайдеры режут исходящий TCP/25 по умолчанию:
Bash:
nc -vz -w5 gmail-smtp-in.l.google.com 25
succeeded — порт открыт. Таймаут — пишу в поддержку хостера, что поднимаю почтовый сервер; обычно открывают после короткой формальной проверки. Без исходящего 25-го дальше можно не читать (либо смотрите опциональный раздел про relayhost ближе к концу).Второе — репутация IP. Прогоняю адрес по чёрным спискам (hetrixtools.com → Blacklist Monitor, mxtoolbox.com). Если хостер выдал адрес из-под бывшего спамера, вы потратите недели на выяснение, почему «всё настроено, а письма не доходят». Дешевле сразу попросить другой IP — я так и делаю.
DNS-записи
Без правильного DNS сервер работать будет, но письма полетят в спам. Минимум, который я завожу до начала настройки:
| Имя | Тип | Значение |
|---|---|---|
| mail.hosterum.ru | A | 192.145.97.212 |
| webmail.hosterum.ru | A | 192.145.97.212 |
| @ (hosterum.ru) | MX 10 | mail.hosterum.ru |
SPF, DKIM и DMARC добавлю позже, когда будет что в них писать.
Обязательный пункт — обратная зона (PTR): IP-адресу должно соответствовать имя
mail.hosterum.ru, один в один совпадающее с A-записью. Настраивается в панели хостера или тикетом. Проверяю так:через bind или curl
для проверки через утилиту bind
ставлю:
apt update && apt upgrade -y && apt install bind9-host -yи проверяю
Bash:
host 192.145.97.212
# если запись сделана верно мы должны увидеть строку ниже
# 212.97.145.192.in-addr.arpa domain name pointer mail.hosterum.ru.
или через curl к публичному DNS API (например, Google DNS):
Bash:
curl "https://dns.google/resolve?name=212.97.145.192.in-addr.arpa&type=PTR"
в ответ
curl "https://dns.google/resolve?name=212.97.145.192.in-addr.arpa&type=PTR"
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"212.97.145.192.in-addr.arpa.","type":12}],"Answer":[{"name":"212.97.145.192.in-addr.arpa.","type":12,"TTL":3600,"data":"mail.hosterum.ru."}],"Comment":"Response from 195.211.100.2."}
Идеальная картина: MX указывает на имя, имя резолвится в IP, IP резолвится обратно в то же имя. Спам-фильтры проверяют именно это тройное соответствие. Без PTR часть серверов вашу почту не примет вовсе — вы для них спамер по умолчанию.
Подготовка сервера
Bash:
apt update && apt install chrony -y # в минимальной установке 26.04 его может не быть
hostnamectl set-hostname mail.hosterum.ru
timedatectl set-timezone Europe/Moscow #выставляем свою таймзону
timedatectl # проверяем
chronyc tracking # синхронизация
Время обязано синхронизироваться — для почты это критично: DKIM-подписи и заголовки с кривыми датами вызывают у принимающих серверов вопросы. В
chronyc tracking смотрю на Leap status: Normal и System time с расхождением в микросекунды-миллисекунды.Отключаю ipv6, ниже настройки для ipv4 который и будет работать
nano /etc/netplan/00-installer-config.yamlтут нужно добавить две строки
Код:
dhcp6: false
accept-ra: false
пример готового конфига
Bash:
# This is the network config written by 'subiquity'
network:
ethernets:
ens18:
addresses:
- 192.145.97.212/24
match:
macaddress: 16:7a:b7:3b:06:44
nameservers:
addresses:
- 8.8.8.8
- 8.8.4.4
search: []
routes:
- to: default
via: 192.145.97.1
set-name: ens18
dhcp6: false
accept-ra: false
version: 2
применяю безопасно
netplan tryподтверждаю
Enterна уровне ядра тоже
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="/GRUB_CMDLINE_LINUX_DEFAULT="ipv6.disable=1 /' /etc/default/grubcat /etc/default/grub # проверить строку глазамиupdate-grubи
rebootПосле перезагрузки убеждаюсь, что v6 действительно мёртв:
Bash:
ip -6 addr
ss -6 -tlnp
# обе команды должны вернуть пустоту
Полумеры через sysctl не использую сознательно: disable_ipv6 снимает адреса, но демоны, которые биндятся на wildcard
::, продолжают создавать v6-сокеты. Полностью убирает AF_INET6 из ядра только загрузочный параметр.Percona Server for MySQL и база для PostfixAdmin
Домены, ящики и алиасы живут в базе. В качестве СУБД я беру Percona Server for MySQL 8.4 LTS — бинарно совместимый drop-in вариант MySQL с расширенной диагностикой из коробки (подробный slow log, статистика по пользователям и индексам) и родным XtraBackup для горячих бэкапов.
В штатных репозиториях Ubuntu Percona отсутствует, ставится через percona-release:
Bash:
apt install curl gnupg2 lsb-release
curl -O https://repo.percona.com/apt/percona-release_latest.generic_all.deb
apt install ./percona-release_latest.generic_all.deb
percona-release setup ps-84-lts
PERCONA_TELEMETRY_DISABLE=1
apt install percona-server-server
Переменная PERCONA_TELEMETRY_DISABLE=1 отключает телеметрию, которую Percona с некоторых пор кладёт в пакеты, — статистика «домой» с моего прода никому не нужна.
Инсталлятор через debconf спросит пароль root (про «Legacy» — ниже). Дальше стандартная зачистка:
Bash:
mysql_secure_installation
Кратко о шагах:
VALIDATE PASSWORD COMPONENT - No
Change the password for root - задать пароль root
Remove anonymous users? - Y
Disallow root login remotely? - Y
Remove test database and access to it? - Y
Reload privilege tables now? - Y
Плагин аутентификации: одна осознанная уступка
В линейке MySQL 8.4 плагин mysql_native_password по умолчанию выключен, всё аутентифицируется через caching_sha2_password. Для веб-части это не проблема: PHP умеет sha2 со времён 7.4, да и ходят PostfixAdmin с Roundcube в базу по unix-сокету. Проблема ровно в одном месте: Postfix ходит в базу по TCP 127.0.0.1 (из-за chroot — объясню в его разделе), собран он с клиентской libmysqlclient24 из состава mysql-8.4, а полная аутентификация caching_sha2 без TLS требует RSA-обмена ключами — и запросить у сервера публичный ключ через карты dict_mysql просто негде, такой опции в .cf-файлах нет. После первого успешного входа mysqld кэширует хэш и пускает по быстрому пути, но кэш живёт в памяти и обнуляется рестартом базы. Итог — отложенный отказ вида «работало месяц, а после планового рестарта mysqld почта встала». Дебажить такое на почтовике — удовольствие ниже среднего.
Поэтому одну служебную учётку — ту, которой пользуются Postfix и Dovecot, — я осознанно завожу на mysql_native_password: она ходит только по loopback и сокету, перехватывать там нечего, а детерминированность мне дороже модного хэша. Включаю плагин в
/etc/mysql/mysql.conf.d/mysqld.cnf, секция [mysqld]:nano /etc/mysql/mysql.conf.d/mysqld.cnf
Код:
mysql_native_password = ON
Код:
bind-address = 127.0.0.1
mysqlx_bind_address = 127.0.0.1
пример конфига
Код:
#
# The Percona Server 8.4 configuration file.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html
[mysqld]
bind-address = 127.0.0.1
mysqlx_bind_address = 127.0.0.1
mysql_native_password = ON
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
log-error = /var/log/mysql/error.log
делаю перезапуск
Bash:
systemctl restart mysql
Граблина от Percona: пункт «Legacy (Native Password)» в debconf-диалоге установщика сам по себе плагин НЕ включает — строку в конфиг всё равно писать руками, иначе на первом же CREATE USER получите «Plugin 'mysql_native_password' is not loaded». Документация об этом честно предупреждает, но кто ж её читает.
Создаю базу и пользователя — и для localhost, и для 127.0.0.1 (зачем Postfix'у TCP — ниже):
mysql -uroot -pмой_парольдалее
SQL:
CREATE DATABASE postfix CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'postfix'@'localhost' IDENTIFIED WITH mysql_native_password BY 'y47Sa3KO';
CREATE USER 'postfix'@'127.0.0.1' IDENTIFIED WITH mysql_native_password BY 'y47Sa3KO';
GRANT ALL PRIVILEGES ON postfix.* TO 'postfix'@'localhost';
GRANT ALL PRIVILEGES ON postfix.* TO 'postfix'@'127.0.0.1';
FLUSH PRIVILEGES;
закончив выхожу
exitгде y47Sa3KO - мой "сложный" пароль
Учётки Roundcube и phpMyAdmin дальше по тексту остаются на дефолтном caching_sha2 — они ходят по сокету через PHP, там всё штатно. Честная цена компромисса: в MySQL 9.x native-плагин выпилен совсем, при переезде на следующий LTS учётку postfix придётся мигрировать одной командой ALTER USER — переживу. Пуристы могут вместо этого поднять TLS до базы на loopback — я в этом виде спорта смысла не вижу.
Кому нужен phpMyAdmin для заглядывания в базу — ниже в статье есть отдельный опциональный раздел про него, с установкой руками и доступом только с разрешённых IP.
nginx и PHP 8.5
В Ubuntu 26.04 дефолтный PHP — 8.5, отсюда все пути вида
/etc/php/8.5/ и сокет /run/php/php8.5-fpm.sock. Ставлю сервер и расширения, которых хватит и PostfixAdmin, и Roundcube:
Bash:
apt install nginx php-fpm php-cli php-mysql php-mbstring php-xml php-intl php-zip php-gd php-curl php-imagick
Пулы php-fpm перекрашивать не нужно: nginx и php-fpm из коробки живут под
www-data.Быстрый фикс
nano /etc/nginx/nginx.conf
Bash:
server_tokens off;
Сертификаты Let's Encrypt
Сертификат получаю сразу, до настройки Postfix и Dovecot — чтобы в их конфигах с первого дня стояли боевые пути, а не самоподписанный мусор, который потом все забывают поменять. Один сертификат на оба имени.
Удаляем дефолтный конфиг
rm /etc/nginx/sites-enabled/defaultСоздаем новый временный конфиг
nano /etc/nginx/sites-available/mail (и симлинк в sites-enabled):
Код:
server {
listen 80;
server_name mail.hosterum.ru webmail.hosterum.ru;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
Bash:
mkdir -p /var/www/letsencrypt
ln -s /etc/nginx/sites-available/mail /etc/nginx/sites-enabled/mail
nginx -t && systemctl start nginx
apt install certbot
certbot certonly --webroot -w /var/www/letsencrypt \
-d mail.hosterum.ru -d webmail.hosterum.ru
ставим certbot для получения и обновления сертов
и получаем серты для двух доменов
кратко шаги:
- Enter email address or hit Enter to skip. - наш ящик для писем
- Please read the Terms of Service at - соглашаемся с условиями - Y
- Would you be willing, once your first certificate is successfully issued - тут вопрос про получение уведомлений, новостей, Y или N на выбор
Успех выглядит так:
Сертификат ляжет в
/etc/letsencrypt/live/mail.hosterum.ru/. Сразу завожу deploy-хук — при продлении Postfix и Dovecot должны перечитать файлы (reload'а достаточно, рестарт не нужен). Сервисов пока нет, хук понадобится при первом продлении через ~60 дней — к тому моменту будут:
Bash:
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh << 'EOF'
#!/bin/sh
systemctl reload nginx postfix dovecot
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh
Для закрытых контуров без Let's Encrypt — самоподписанный на 10 лет одной командой, пути в конфигах ниже поменяете на свои:
Bash:
openssl req -new -x509 -days 3650 -nodes -newkey rsa:2048 \
-keyout /etc/ssl/private/mail.key -out /etc/ssl/certs/mail.pem \
-subj "/CN=mail.hosterum.ru"
PostfixAdmin
Панель управления ящиками. В репозитории Ubuntu лежит старая ветка, поэтому беру релиз с GitHub — на момент написания актуальна 4.0.5, сверьтесь с релизами:
Bash:
apt install acl
cd /tmp
wget https://github.com/postfixadmin/postfixadmin/releases/download/v4.0.5/postfixadmin-v4.0.5-php84.tar.gz
tar xzf postfixadmin-v4.0.5-php84.tar.gz
mv postfixadmin-v4.0.5-php84 /var/www/postfixadmin
mkdir /var/www/postfixadmin/templates_c
chown -R www-data:www-data /var/www/postfixadmin
Мой
nano /var/www/postfixadmin/config.local.php:
PHP:
<?php
$CONF['configured'] = true;
$CONF['setup_password'] = ''; // хэш вставлю со страницы setup.php
$CONF['default_language'] = 'ru';
$CONF['database_type'] = 'mysqli';
$CONF['database_host'] = 'localhost';
$CONF['database_user'] = 'postfix';
$CONF['database_password'] = 'y47Sa3KO';
$CONF['database_name'] = 'postfix';
$CONF['admin_email'] = 'admin@hosterum.ru';
$CONF['encrypt'] = 'php_crypt:SHA512';
$CONF['domain_path'] = 'YES';
$CONF['domain_in_mailbox'] = 'YES';
$CONF['default_aliases'] = array (
'abuse' => 'admin@hosterum.ru',
'hostmaster' => 'admin@hosterum.ru',
'postmaster' => 'admin@hosterum.ru',
'webmaster' => 'admin@hosterum.ru',
);
$CONF['show_footer_text'] = 'NO';
По параметрам:
- encrypt = php_crypt:SHA512 — sha512-crypt ($6$), Dovecot проверяет его штатной схемой CRYPT. В старых мануалах тут md5crypt — в 2026 году за такое должно быть стыдно. Вариант cleartext («удобно смотреть пароли пользователей») существует, но я такое в проде не держу — решайте сами, чей дамп базы утечёт первым.
- domain_path + domain_in_mailbox — дают путь к ящику вида
/var/mail/vhosts/hosterum.ru/user@hosterum.ru. С несколькими доменами в такой иерархии удобно жить.
Установщик скачает composer и подтянет зависимости:
Bash:
cd /var/www/postfixadmin
export COMPOSER_ALLOW_SUPERUSER=1
bash install.sh
Теперь nginx-хост для панели,
/etc/nginx/sites-available/padmin. Панель я наружу без basic auth не выставляю — в ней находили дыры, а это ключи от всей почты:Ставим и запускаем утилиту для создания пароля для пользователя padmin - его можно задать своим
Bash:
apt install apache2-utils
htpasswd -c /etc/nginx/.htpasswd padmin
далее конфиг
nano /etc/nginx/sites-available/padmin
Код:
server {
listen 443 ssl;
http2 on;
server_name mail.hosterum.ru;
ssl_certificate /etc/letsencrypt/live/mail.hosterum.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.hosterum.ru/privkey.pem;
root /var/www/postfixadmin/public;
index index.php;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
access_log /var/log/nginx/padmin-access.log;
error_log /var/log/nginx/padmin-error.log;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
}
делаю симлинк, проверяю и перезапускаю
Bash:
ln -s /etc/nginx/sites-available/padmin /etc/nginx/sites-enabled/padmin
nginx -t && systemctl reload nginx
Проверяю права на /tmp — mysqld под капотом ALTER TABLE пишет туда временные таблицы:
Bash:
ls -ld /tmp
# ожидаю: drwxrwxrwt
На чистой системе так и есть; если sticky-бит или права на запись потерялись (чужие «харденинги», кривая миграция) — возвращаю
chmod 1777 /tmp. Симптом кривых прав на этом шаге: /setup.php падает с «Something went wrong while trying to apply database updates, a message should be logged - check PHP's error_log ()», а в error log MySQL при этом лежит настоящая причина: «Can't create/write to file '/tmp/...' (OS errno 13 - Permission denied)». Проходил.Иду на
https://mail.hosterum.ru/setup.php.ввожу свой логин padmin и пароль и вижу страницу
Все проверки должны быть зелёными. В форме Generate setup_password генерирую хэш ввожу пароль например g6dDQC31 (обязательно задай свой и не копируй мой) и нажимаю generate, вставляю его в
config.local.php,nano /var/www/postfixadmin/config.local.php
Bash:
<?php
$CONF['configured'] = true;
$CONF['setup_password'] = '$2y$12$MhBIWSsyVotaltIWniHfc.3JkXw3GJmAagnSQNe4NMQLH53VAhO6m';
$CONF['default_language'] = 'ru';
$CONF['database_type'] = 'mysqli';
$CONF['database_host'] = 'localhost';
$CONF['database_user'] = 'postfix';
$CONF['database_password'] = 'y47Sa3KO';
$CONF['database_name'] = 'postfix';
$CONF['admin_email'] = 'admin@hosterum.ru';
$CONF['encrypt'] = 'php_crypt:SHA512';
$CONF['domain_path'] = 'YES';
$CONF['domain_in_mailbox'] = 'YES';
$CONF['default_aliases'] = array (
'abuse' => 'admin@hosterum.ru',
'hostmaster' => 'admin@hosterum.ru',
'postmaster' => 'admin@hosterum.ru',
'webmaster' => 'admin@hosterum.ru',
);
$CONF['show_footer_text'] = 'NO';
обновляю страницу и внизу создаю администратора панели.
Setup password это пароль который задан в качестве хэша g6dDQC31 пишу его в это поле
Админ root@hosterum.ru
пароль для root@hosterum.ru у меня в этом поле zP3N12wY (у тебя свой)
пароль еще раз zP3N12wY
при успехе должен увидеть подтверждение по добавленному админу
Postfix
Bash:
apt install postfix postfix-mysql
Вопросы дебконфа неважны (Internet Site, имя как в DNS) — конфиг всё равно пишу свой. Сохраняю оригинал и заменяю
/etc/postfix/main.cf:
Bash:
cp /etc/postfix/main.cf /etc/postfix/main.cf.orig
затру оригинал
> /etc/postfix/main.cfследом добавляю свой
nano /etc/postfix/main.cf
Код:
compatibility_level = 3.9
myhostname = mail.hosterum.ru
mydomain = hosterum.ru
myorigin = $myhostname
inet_interfaces = all
inet_protocols = ipv4
mydestination = localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8
unknown_local_recipient_reject_code = 550
recipient_delimiter = +
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
smtpd_banner = $myhostname ESMTP
message_size_limit = 20971520
maximal_queue_lifetime = 1d
bounce_queue_lifetime = 1d
smtpd_helo_required = yes
smtpd_delay_reject = yes
disable_vrfy_command = yes
smtpd_discard_ehlo_keywords = etrn, silent-discard
smtpd_forbidden_commands = CONNECT GET POST
# Домены, ящики и алиасы — из базы PostfixAdmin
virtual_mailbox_domains = mysql:/etc/postfix/mysql/virtual_mailbox_domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql/virtual_mailbox_maps.cf
virtual_alias_maps = mysql:/etc/postfix/mysql/virtual_alias_maps.cf,
mysql:/etc/postfix/mysql/virtual_alias_domain_maps.cf
relay_domains = mysql:/etc/postfix/mysql/relay_domains.cf
# Доставка — в Dovecot по LMTP
virtual_transport = lmtp:unix:private/dovecot-lmtp
# SASL-аутентификация через Dovecot
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/dovecot-auth
smtpd_sasl_security_options = noanonymous
broken_sasl_auth_clients = yes
# TLS
smtpd_tls_cert_file = /etc/letsencrypt/live/mail.hosterum.ru/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mail.hosterum.ru/privkey.pem
smtpd_tls_security_level = may
smtpd_tls_loglevel = 1
smtpd_tls_received_header = yes
smtp_tls_security_level = may
smtp_tls_loglevel = 1
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
smtp_tls_session_cache_database = btree:$data_directory/smtp_tls_session_cache
#smtp_tls_policy_maps = hash:/etc/postfix/tls_policy_maps
# Ограничения на приём
smtpd_helo_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_non_fqdn_helo_hostname,
reject_invalid_helo_hostname
smtpd_sender_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_non_fqdn_sender,
reject_unknown_sender_domain
smtpd_recipient_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_non_fqdn_recipient,
reject_unknown_recipient_domain,
reject_unauth_destination,
reject_unlisted_recipient,
reject_multi_recipient_bounce
smtpd_data_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_pipelining,
reject_multi_recipient_bounce
# Лимиты против особо настойчивых
smtpd_soft_error_limit = 10
smtpd_hard_error_limit = 15
smtpd_error_sleep_time = 20
smtpd_client_connection_count_limit = 20
smtpd_client_connection_rate_limit = 30
smtpd_client_message_rate_limit = 30
smtpd_client_event_limit_exceptions = 127.0.0.0/8
smtpd_client_connection_limit_exceptions = 127.0.0.0/8
Что здесь принципиально и почему я сделал именно так:
- virtual_transport = lmtp вместо классического pipe в
dovecot-lda. Pipe порождает отдельный процесс deliver на каждое письмо; LMTP-сокет держит один процесс Dovecot и рекомендован документацией. Меньше форков — меньше нагрузка, и sieve-фильтры отрабатывают там же. - inet_protocols = ipv4 — осознанно. Для IPv6 нужны AAAA-запись и PTR на v6-адрес, иначе Gmail и компания будут резать письма, ушедшие через v6. Проще выключить, чем настроить наполовину.
- Из конфига выкинуто всё, что совпадает с дефолтами Postfix 3.10: пути каталогов, debugger_command, tls_random_source, ручные списки шифров и протоколов. Дефолтные TLS-настройки 3.10 разумны (минимум TLSv1), велосипед не изобретаю.
smtp_tls_policy_mapsоставил закомментированным — пригодится, если придётся общаться с музейным сервером, не умеющим современный TLS. - Ручное шифрование паролей, uid-маппинги и virtual_mailbox_base Postfix'у не нужны — файловой системой ящиков теперь целиком заведует Dovecot.
Подключение к базе
Bash:
mkdir /etc/postfix/mysql && cd /etc/postfix/mysql
Во всех файлах —
hosts = 127.0.0.1:3306, а не localhost. Это не опечатка: localhost для клиента MySQL означает unix-сокет, а smtpd в Ubuntu работает в chroot /var/spool/postfix, откуда сокета /run/mysqld не видно. TCP на loopback из chroot работает всегда. Ради этого я и заводил пользователя базы для 127.0.0.1 — и ради этого же его учётка живёт на mysql_native_password.nano virtual_mailbox_domains.cf:
Код:
hosts = 127.0.0.1:3306
user = postfix
password = y47Sa3KO
dbname = postfix
query = SELECT domain FROM domain WHERE domain='%s' AND backupmx='0' AND active='1'
nano virtual_mailbox_maps.cf:
Код:
hosts = 127.0.0.1:3306
user = postfix
password = y47Sa3KO
dbname = postfix
query = SELECT maildir FROM mailbox WHERE username='%s' AND active='1'
nano virtual_alias_maps.cf:
Код:
hosts = 127.0.0.1:3306
user = postfix
password = y47Sa3KO
dbname = postfix
query = SELECT goto FROM alias WHERE address='%s' AND active='1'
nano virtual_alias_domain_maps.cf:
Код:
hosts = 127.0.0.1:3306
user = postfix
password = y47Sa3KO
dbname = postfix
query = SELECT goto FROM alias, alias_domain WHERE alias_domain.alias_domain='%d' AND alias.address=CONCAT('%u','@',alias_domain.target_domain) AND alias.active='1' AND alias_domain.active='1'
nano relay_domains.cf:
Код:
hosts = 127.0.0.1:3306
user = postfix
password = y47Sa3KO
dbname = postfix
query = SELECT domain FROM domain WHERE domain='%s' AND backupmx='1'
В файлах лежит пароль от базы — прикрываю:
Bash:
chown -R root:postfix /etc/postfix/mysql
chmod 750 /etc/postfix/mysql
chmod 640 /etc/postfix/mysql/*
master.cf: порты 587 и 465
Смартфоны и Outlook хотят submission (587, STARTTLS) и smtps (465, TLS-wrapper). Добавляю в
/etc/postfix/master.cf (пробел в начале строк с -o обязателен — это продолжение записи):Добавляю в конец
Код:
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_tls_auth_only=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_reject_unlisted_recipient=no
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
Postfix пока не запускаю — он завязан на сокеты Dovecot, которых ещё нет.
Опционально: копии всей переписки
Если нужен служебный архив входящей/исходящей (аудит, «я этого письма не получал»), добавьте в /etc/postfix/main.cf:
Код:
recipient_bcc_maps = hash:/etc/postfix/recipient_bcc_maps
sender_bcc_maps = hash:/etc/postfix/sender_bcc_maps
Содержимое файлов — по строке на домен, ящики создать в PostfixAdmin:
Код:
# recipient_bcc_maps
@hosterum.ru all_in@hosterum.ru
# sender_bcc_maps
@hosterum.ru all_out@hosterum.ru
Bash:
postmap /etc/postfix/recipient_bcc_maps /etc/postfix/sender_bcc_maps
Учтите: почтовая база начнёт расти с двойной скоростью, эти ящики надо регулярно чистить.
Опционально: отправка через внешний релей (relayhost)
Сценарий: 25-й порт наружу закрыт намертво, или IP с несмываемой репутацией, или хостер даёт свой SMTP-релей и требует слать через него. Тогда вся исходящая почта уходит не напрямую по MX получателей, а через smarthost:
Код:
relayhost = [smtp.hoster.example]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
Квадратные скобки вокруг имени отключают MX-lookup — подключаемся именно к этому хосту. Учётка релея:
Bash:
echo '[smtp.hoster.example]:587 логин:пароль' > /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
systemctl reload postfix
Три момента, о которых забывают. Первый: принимающая сторона видит IP релея, а не ваш, поэтому SPF-запись обязана разрешать релей — обычно это
include:_spf.hoster.example от провайдера релея. Второй: DKIM-подпись остаётся вашей и валидной — милтер подписывает письмо до того, как оно уходит на релей (если релей не переписывает тело, а приличные не переписывают). Третий: через релей пойдёт вся исходящая; если нужно гнать через smarthost только отдельные направления — смотрите в сторону transport_maps и sender_dependent_relayhost_maps, но это уже за рамками статьи.Обратная задача — сам сервер как релей (резервный MX для чужого домена) — у нас уже наполовину готова: карта relay_domains подключена, в PostfixAdmin у домена есть галка «резервный MX». Поставили галку, добавили MX 20 в DNS чужого домена — и сервер принимает и придерживает почту, пока основной лежит. Для прода к этому обязательно добавляется
relay_recipient_maps со списком валидных ящиков, иначе будете копить и рассылать backscatter за несуществующих получателей.Dovecot 2.4: конфиг переписали полностью
Главная причина, по которой умерли все старые статьи. Краткий словарь для тех, кто, как и я, переезжает с 2.3:
| Было (2.3) | Стало (2.4) |
|---|---|
| ssl_cert = </path/cert.pem | ssl_server_cert_file = /path/cert.pem |
| ssl_key = </path/key.pem | ssl_server_key_file = /path/key.pem |
| mail_location = maildir:/path | mail_driver = maildir + mail_path = /path |
| %d, %n, %u | %{user|domain}, %{user|username}, %{user} |
| disable_plaintext_auth = yes | auth_allow_cleartext = no |
| passdb { driver=sql; args=dovecot-sql.conf.ext } | sql_driver + блок mysql {} + passdb sql {} с query прямо в конфиге |
| mail_plugins = $mail_plugins sieve | mail_plugins { sieve = yes } |
| — | обязательные dovecot_config_version и dovecot_storage_version в начале |
Файл
dovecot-sql.conf.ext больше не существует — SQL-настройки живут в основном конфиге. Без строки dovecot_config_version демон не стартует вообще.
Bash:
apt install dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql dovecot-sieve dovecot-managesieved
Конфигурация по умолчанию размазана по
conf.d. Я держу всё в одном файле — так видно систему целиком. Мой файл полностью заменяет штатный, строки !include conf.d в нём сознательно нет:
Bash:
cp /etc/dovecot/dovecot.conf /etc/dovecot/dovecot.conf.orig
/etc/dovecot/dovecot.conf целиком:
Код:
dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
listen = *
protocols = imap pop3 lmtp sieve
# Хранилище: Maildir под vmail (1100:1100)
mail_driver = maildir
mail_home = /var/mail/vhosts/%{user|domain}/%{user|username}@%{user|domain}
mail_path = /var/mail/vhosts/%{user|domain}/%{user|username}@%{user|domain}
mailbox_list_layout = fs
mail_uid = 1100
mail_gid = 1100
first_valid_uid = 1100
last_valid_uid = 1100
mail_plugins = acl
# Аутентификация
auth_mechanisms = PLAIN LOGIN
auth_allow_cleartext = no
auth_default_domain = hosterum.ru
auth_verbose = yes
# Логи
log_path = /var/log/dovecot/main.log
info_log_path = /var/log/dovecot/info.log
deliver_log_format = from=%{from} msgid=%{msgid}: %{message} (subject=%{subject} size=%{size})
lda_mailbox_autocreate = yes
lda_mailbox_autosubscribe = yes
# TLS
ssl_min_protocol = TLSv1.2
ssl_server_cert_file = /etc/letsencrypt/live/mail.hosterum.ru/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/mail.hosterum.ru/privkey.pem
# SQL: подключение и запросы теперь прямо здесь
sql_driver = mysql
mysql localhost {
user = postfix
password = y47Sa3KO
dbname = postfix
}
passdb sql {
default_password_scheme = CRYPT
query = SELECT username AS user, password FROM mailbox WHERE username = '%{user}' AND active = '1'
}
userdb sql {
query = SELECT '/var/mail/vhosts/%{user|domain}/%{user|username}@%{user|domain}' AS home, 'maildir:/var/mail/vhosts/%{user|domain}/%{user|username}@%{user|domain}' AS mail, 1100 AS uid, 1100 AS gid FROM mailbox WHERE username = '%{user}' AND active = '1'
}
# Сокеты для Postfix
service auth {
unix_listener /var/spool/postfix/private/dovecot-auth {
user = postfix
group = postfix
mode = 0660
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
user = postfix
group = postfix
mode = 0600
}
}
service imap-login {
process_limit = 100
client_limit = 10
}
service pop3-login {
process_limit = 100
client_limit = 10
}
service managesieve-login {
inet_listener sieve {
port = 4190
}
}
service stats {
unix_listener stats-reader {
user = vmail
group = vmail
mode = 0660
}
unix_listener stats-writer {
user = vmail
group = vmail
mode = 0660
}
}
# Sieve: фильтры пользователей
sieve_script personal {
driver = file
path = ~/.sieve
active_path = ~/.dovecot.sieve
}
# ACL: общие папки
acl_driver = vfile
acl_sharing_map {
dict file {
path = /var/mail/vhosts/shared-folders/shared-mailboxes.db
}
}
protocol imap {
mail_plugins {
imap_acl = yes
}
imap_client_workarounds = tb-extra-mailbox-sep
mail_max_userip_connections = 30
}
protocol pop3 {
pop3_client_workarounds = outlook-no-nuls oe-ns-eoh
pop3_uidl_format = %{uid | hex(8)}%{uidvalidity | hex(8)}
mail_max_userip_connections = 10
}
protocol lmtp {
mail_plugins {
sieve = yes
}
postmaster_address = postmaster@hosterum.ru
lmtp_save_to_detail_mailbox = yes
recipient_delimiter = +
info_log_path = /var/log/dovecot/lmtp.log
}
namespace inbox {
type = private
separator = /
prefix =
inbox = yes
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox "Sent Messages" {
auto = no
special_use = \Sent
}
mailbox "Sent Items" {
auto = no
special_use = \Sent
}
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox "Deleted Messages" {
auto = no
special_use = \Trash
}
mailbox Junk {
auto = subscribe
special_use = \Junk
}
mailbox Spam {
auto = no
special_use = \Junk
}
mailbox "Junk E-mail" {
auto = no
special_use = \Junk
}
mailbox Archive {
auto = no
special_use = \Archive
}
mailbox Archives {
auto = no
special_use = \Archive
}
}
namespace shared {
type = shared
separator = /
prefix = Shared/$user/
list = children
subscriptions = yes
mail_driver = maildir
mailbox_list_layout = fs
mail_path = %{owner_home}
mail_index_path = ~/Shared/%{owner_user}
}
Комментарии к спорным местам:
- default_password_scheme = CRYPT — libcrypt в Ubuntu (libxcrypt) сам понимает префиксы $6$, $2y$ и прочие, так что sha512-хэши из PostfixAdmin проверяются без плясок. Если решили хранить пароли открыто (encrypt = cleartext) — здесь ставьте PLAIN.
- ssl_min_protocol = TLSv1.2. Почта — консервативная область, и если у вас в хозяйстве клиенты эпохи Windows XP, опустите до TLSv1. У меня таких нет.
- Блок namespace inbox с кучей mailbox-описаний решает вечный зоопарк: Thunderbird создаёт Sent, Outlook — Sent Items, мобильные клиенты — что-то своё. Special-use-атрибуты сводят всё в одни папки.
- Плагины
notify+mail_log(аудит действий в ящиках: кто что удалил) я не включаю — лишняя запись в лог на каждое действие. Кому нужно — добавляется в mail_plugins. - В гуляющих по сети конфигах в protocol lmtp болтается плагин quota без единой настройки квот — выкинул. В 2.4 квоты конфигурируются иначе, и полуподключённый плагин — это заявка на нестарт сервиса. Квоты мне не нужны: проще ограничить размер письма и докупить диск, чем пинать пользователей чистить ящики.
Пользователь vmail, каталоги, логи
Bash:
groupadd -g 1100 vmail
useradd -u 1100 -g vmail -d /var/mail/vhosts -s /usr/sbin/nologin vmail
usermod -aG dovecot vmail
mkdir -p /var/mail/vhosts/shared-folders
chown -R vmail:vmail /var/mail/vhosts
mkdir /var/log/dovecot
touch /var/log/dovecot/{main,info,lmtp}.log
chown -R vmail:dovecot /var/log/dovecot
Проверка конфига перед стартом —
doveconf -n обязан отработать без ошибок. Это первое, что я запускаю после любой правки dovecot.conf.Ротация логов,
/etc/logrotate.d/dovecot:
Код:
/var/log/dovecot/*.log {
weekly
rotate 4
compress
missingok
notifempty
postrotate
doveadm log reopen
endscript
create 0644 vmail dovecot
}
Первый запуск и проверка
Bash:
systemctl restart postfix dovecot
systemctl enable postfix dovecot
Postfix пишет в syslog. В минимальной установке 26.04 текстового
/var/log/mail.log может не быть — всё уходит в journald. Мне для отладки и fail2ban удобнее классический файл:
Bash:
apt install rsyslog
Логинюсь через /login.php (https://mail.hosterum.ru/login.php), добавляю домен (Список доменов → Новый домен) и первый ящик (Обзор → Создать ящик).
Smoke-тест SQL-карт — до всяких писем:
Bash:
postmap -q hosterum.ru mysql:/etc/postfix/mysql/virtual_mailbox_domains.cf
# должен вернуть: hosterum.ru
postmap -q admin@hosterum.ru mysql:/etc/postfix/mysql/virtual_mailbox_maps.cf
# должен вернуть: hosterum.ru/admin@hosterum.ru/
Пусто — значит, Postfix не достучался до базы или ящика нет. Заодно это проверка связки libmysqlclient + Percona + mysql_native_password: если тут ошибка аутентификации — возвращайтесь в раздел про базу, а не копайте почтовые конфиги.
Дальше шлю письмо снаружи (с gmail) на admin@hosterum.ru и смотрю
tail -f /var/log/mail.log. Правильная картина заканчивается строкой вида:
Код:
postfix/lmtp[...]: ...: to=<admin@hosterum.ru>, relay=mail.hosterum.ru[private/dovecot-lmtp],
... status=sent (250 2.0.0 <admin@hosterum.ru> ... Saved)
Письмо физически лежит в
/var/mail/vhosts/hosterum.ru/admin@hosterum.ru/new/ — обычный текстовый файл, можно открыть глазами. Каталог ящика создаётся при первом письме. Факт доставки и все подключения к ящикам — в /var/log/dovecot/lmtp.log и /var/log/dovecot/info.log. Неудачная аутентификация выглядит так (эти строки ещё пригодятся для fail2ban):
Код:
Jul 25 21:13:09 auth-worker(user@hosterum.ru,203.0.113.66)<12345><xLr9cXNXctF/AAAB>: request [1]: Info: sql: Password mismatch
Jul 25 21:13:20 auth-worker(office@hosterum.ru,203.0.113.66)<12346><u0hucXNXfNF/AAAB>: request [1]: Info: sql: unknown user
Проверяю клиентом: IMAP — mail.hosterum.ru, порт 993, SSL/TLS; SMTP — порт 465 (SSL) или 587 (STARTTLS); логин — полный адрес ящика, не короткое имя. Автоопределение в Thunderbird любит угадать неправильно — проверяйте руками.
Отправить наружу можно уже сейчас, но Gmail завернёт письмо: «sender is unauthenticated, SPF/DKIM did not pass». Это ожидаемо — идём настраивать подписи. Три лога для любых разборов:
/var/log/mail.log (postfix), /var/log/dovecot/lmtp.log (что и куда доставлено), /var/log/dovecot/info.log (кто и откуда подключался).Roundcube
Веб-интерфейс для пользователей плюс единственный вменяемый способ управлять sieve-фильтрами и общими папками. Ветка 1.7 уже вышла. Актуальный номер сверяйте на github.
Bash:
cd /tmp
wget https://github.com/roundcube/roundcubemail/releases/download/1.7.2/roundcubemail-1.7.2-complete.tar.gz
tar xzf roundcubemail-1.7.2-complete.tar.gz
mkdir /var/www/webmail
cp -R roundcubemail-1.7.2/. /var/www/webmail/
chown -R www-data:www-data /var/www/webmail
Сборка -complete уже содержит все PHP-зависимости в vendor/ — доставлять пакеты php-net-sieve и прочие, как советуют старые мануалы, не нужно. База (учётка остаётся на дефолтном caching_sha2 — PHP по сокету с ним дружит):
SQL:
CREATE DATABASE roundcube CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'roundcube'@'localhost' IDENTIFIED BY 'j0v4p2iG';
GRANT ALL PRIVILEGES ON roundcube.* TO 'roundcube'@'localhost';
FLUSH PRIVILEGES;
Правки в
/etc/php/8.5/fpm/php.ini (лимиты с запасом под message_size_limit = 20 МБ):
Код:
post_max_size = 32M
upload_max_filesize = 32M
date.timezone = Europe/Moscow
Bash:
systemctl restart php8.5-fpm
nginx-хост
/etc/nginx/sites-available/webmail. Важная деталь ветки 1.7: вся статика (CSS, JS, картинки скина) раздаётся через static.php — каталоги skins и program вынесены за пределы docroot public_html, и напрямую nginx их не отдаст. Поэтому в конфиге обязателен отдельный location для static.php с PATH_INFO — без него страница откроется голым HTML без единого стиля:
Код:
server {
listen 443 ssl;
http2 on;
server_name webmail.hosterum.ru;
ssl_certificate /etc/letsencrypt/live/mail.hosterum.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.hosterum.ru/privkey.pem;
root /var/www/webmail/public_html;
index index.php;
access_log /var/log/nginx/webmail-access.log;
error_log /var/log/nginx/webmail-error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ ^/(config|temp|logs|SQL|bin|installer|vendor)/ {
deny all;
}
location ~ /\. {
deny all;
}
location ~ \.(md|json|lock)$ {
deny all;
}
location ~ ^/static\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
set $path_info $fastcgi_path_info;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $path_info;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
}
Две неочевидные строки в блоке static.php:
set $path_info обязан стоять ДО try_files — try_files сбрасывает $fastcgi_path_info, и если читать её после, в PHP приедет пустота; нежадный +? в регекспе сплита защищает от путей с несколькими точками.
Bash:
ln -s /etc/nginx/sites-available/webmail /etc/nginx/sites-enabled/webmail
nginx -t && systemctl reload nginx
Установщик —
https://webmail.hosterum.ru/installer.php. Все проверки OK (кроме чужих баз), на втором шаге указываю:- Database setup — база roundcube и её пользователь;
- imap_host —
localhost:143; - smtp_host —
127.0.0.1:25, галка Use the current IMAP username and password for SMTP authentication; - language — ru_RU, skin — elastic;
- плагины — acl, managesieve (плюс password, если хотите дать пользователям смену пароля).
CREATE CONFIG → CONTINUE → кнопка Initialize database. После установки — обязательно:
Bash:
rm -rf /var/www/webmail/installer
Логинюсь полным адресом ящика. Ошибки Roundcube — в
/var/www/webmail/logs/errors.log.ротация логов
nano /etc/logrotate.d/roundcube
Bash:
/var/www/webmail/logs/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
create 0640 www-data www-data
}
Проверка без реальной ротации
logrotate -d /etc/logrotate.d/roundcubeSieve-фильтры и автоответчик
Sieve выполняет сортировку в момент доставки на сервере — правило работает во всех клиентах сразу. Конфиг плагина:
Bash:
cd /var/www/webmail/plugins/managesieve
cp config.inc.php.dist config.inc.php
PHP:
$config['managesieve_host'] = '127.0.0.1';
$config['managesieve_port'] = 4190;
$config['managesieve_usetls'] = false;
$config['managesieve_vacation'] = 1;
Последний параметр включает автоответчик: в интерфейсе появится Настройки → Вне офиса — под капотом это то же sieve-правило. Обычные фильтры — Настройки → Фильтры; папку назначения создайте заранее в Настройки → Папки. На диске правила лежат в
~/.sieve/, активный скрипт — симлинк .dovecot.sieve; их можно раскатывать пользователям скриптом.Общие папки по IMAP
Плагин acl я уже включил с обеих сторон. Пользователь создаёт папку (Настройки → Папки), открывает её свойства и раздаёт права на чтение/запись конкретным ящикам или всему домену. У получателя папка появляется в дереве Shared. Удобно для общего ящика «на вход»: в отличие от алиаса на десять человек, письмо хранится в одном экземпляре, а не размножается по всем ящикам.
DKIM
DKIM — цифровая подпись исходящих писем ключом домена; открытая часть публикуется в DNS, принимающий сервер сверяет. От спама не защищает (спамер подпишет свой домен так же), но однозначно подтверждает, что письмо от вас, — и без неё Gmail с Яндексом сегодня почту просто не принимают.
Bash:
apt install opendkim opendkim-tools
mkdir -p /etc/opendkim/keys
opendkim-genkey -b 2048 -d hosterum.ru -s mail -D /etc/opendkim/keys
mv /etc/opendkim/keys/mail.private /etc/opendkim/keys/hosterum.ru.private
mv /etc/opendkim/keys/mail.txt /etc/opendkim/keys/hosterum.ru.txt
Селектор
mail — произвольное слово, я беру имя сервера. 2048 бит указываю явно: генератор по умолчанию до сих пор делает 1024./etc/opendkim/keytable (по строке на домен):
Код:
mail._domainkey.hosterum.ru hosterum.ru:mail:/etc/opendkim/keys/hosterum.ru.private
/etc/opendkim/signingtable:
Код:
*@hosterum.ru mail._domainkey.hosterum.ru
Права:
Bash:
chown -R opendkim:opendkim /etc/opendkim
chmod 700 /etc/opendkim/keys
chmod 600 /etc/opendkim/keys/hosterum.ru.private
/etc/opendkim.conf (заменяю целиком, оригинал — в .orig):
Код:
Syslog yes
SyslogSuccess yes
Canonicalization relaxed/relaxed
Mode sv
SubDomains no
UserID opendkim
UMask 007
Socket inet:8891@localhost
PidFile /run/opendkim/opendkim.pid
KeyTable /etc/opendkim/keytable
SigningTable refile:/etc/opendkim/signingtable
Милтер слушаю по TCP на loopback, а не через unix-сокет — smtpd в chroot'е, и с сокетом пришлось бы возиться с путями внутри /var/spool/postfix. Две граблины Ubuntu-пакета:
- Загляните в
/etc/default/opendkim: если там раскомментирована строка SOCKET, она перекроет значение из opendkim.conf. Либо приведите её кSOCKET=inet:8891@localhost, либо закомментируйте; в свежих версиях пакета после правки этого файла нужно выполнить/lib/opendkim/opendkim.service.generate && systemctl daemon-reload. - Параметры KeyFile и Selector при использовании KeyTable/SigningTable игнорируются — в половине мануалов они зачем-то стоят рядом. Не копируйте мусор.
Подключаю милтер к Postfix — в конец
/etc/postfix/main.cf:
Код:
smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = $smtpd_milters
milter_default_action = accept
Bash:
systemctl enable --now opendkim
systemctl restart postfix
Частый вопрос: подпишутся ли письма пользователей, которые сидят по SASL с внешних IP через 587-й порт? Подпишутся, и никакие списки InternalHosts с офисными сетями не нужны. OpenDKIM подписывает письмо, если выполнено хотя бы одно из условий: клиент из InternalHosts (127.0.0.1 по умолчанию — покрывает Roundcube) или клиент прошёл аутентификацию — Postfix передаёт милтеру макрос {auth_type} в дефолтном milter_mail_macros, и любой успешный SASL-логин включает подпись.
Открытый ключ — в DNS. Содержимое
more /etc/opendkim/keys/hosterum.ru.txt выглядит так:
Код:
mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvmHGRsoJTypNtdV83fnYyYWA0nYbctXA1D9ZKM/3ahUYwENWVwCQL9qY/1qHrQ/d7jKJ6aejdrLESx8Cd59hMucSe4j49AUXz3An06cG9m7UUxfCMgL70lxj9n7WmxJ/ywLB6OH3YQHBR/1a/bMkBKccYH2yVYsKZkQ7otNvCO4GSgbH8Xsb
p0oXIGshs9qOsMQnWaiDk8I7fX"
"s6ZS/43YmW0Awlceett5Zxnd8+prmTVD1FMgvG01Y7by3P5lERl2HVP6Z0mye4LoGyxJxShG5RciOxxUj9/upGY/5V+lf6FGIBzBe41O69J0RgfmiUY6cPa2r6EU+JisZkyd22wQIDAQAB" ) ; ----- DKIM key mail for hosterum.ru
mail._domainkey.hosterum.ru, в значение вставляю всё от v=DKIM1 до конца p=, убрав кавычки и переносы. Для 2048-битного ключа значение длиннее 255 байт — нормальные DNS-панели режут его на куски сами, это легально. Проверка:apt install dnsutils - для использования команды digdig +short TXT mail._domainkey.hosterum.ru - проверкаТест: письмо на gmail → «Показать оригинал» → DKIM: PASS. В mail.log при этом появляется строка
opendkim[...]: ... DKIM-Signature field added (s=mail, d=hosterum.ru).SPF и DMARC
SPF — TXT-запись, перечисляющая, кому разрешено слать почту от имени домена:
Код:
hosterum.ru TXT "v=spf1 ip4:192.145.97.212 ~all"
ip4: — константа, ноль DNS-запросов: у SPF жёсткий лимит в 10 lookup'ов на проверку, и тратить их на резолв ради значения, которое я и так знаю, незачем. Альтернатива mx («те же хосты, что в MX») подтянет смену IP сама, но стоит резолвов на каждую входящую проверку — я IP меняю раз в пятилетку, размен очевиден. Если почту от имени домена шлёт ещё и веб-сервер с магазином (бывает), добавьте его вторым механизмом: ip4:203.0.113.20. ~all — мягкий отказ для остальных; -all жёстче, но его я включаю только когда уверен, что перечислил все источники.Вот так это выглядит:
dig +short TXT hosterum.ru | grep spf - проверкаВажно: SPF-запись на домене должна быть ровно одна. Две TXT с v=spf1 — это не «двойное разрешение», а PermError по RFC 7208: принимающая сторона отбрасывает обе, и SPF перестаёт работать вообще. Если панель DNS предупреждает, что запись уже существует, — правьте существующую, а не добавляйте вторую.
DMARC — политика для принимающей стороны: что делать с письмами, не прошедшими SPF/DKIM. На сервере настраивать нечего, только DNS:
первый вариант
Код:
_dmarc.hosterum.ru TXT "v=DMARC1; p=none; rua=mailto:dmarc@hosterum.ru"
Начать можно с
p=none + отчёты (ящик dmarc завести). Пару недель понаблюдал, убедился, что легитимная почта проходит, — ужесточаю до reject.второй вариант
Код:
_dmarc.hosterum.ru TXT "v=DMARC1; p=reject; pct=100"
Несколько доменов на одном сервере
Вся конфигурация выше изначально мультидоменная — SQL-запросы Postfix и Dovecot работают с любым количеством доменов, конфиги при добавлении второго домена не трогаются вообще. Порядок действий для домена filehost.su:
1. PostfixAdmin. Список доменов → Новый домен → filehost.su. Ящики создаются там же. Всё, база знает о домене — Postfix и Dovecot тоже.
2. DNS второго домена. MX указывает на существующее имя сервера — новой A-записи не нужно:
Код:
filehost.su MX 10 mail.hosterum.ru
filehost.su TXT "v=spf1 ip4:192.145.97.212 ~all"
_dmarc.filehost.su TXT "v=DMARC1; p=reject; pct=100"
3. DKIM. Единственное место, где нужны руки: ключ на каждый домен свой. Генерирую и дописываю по строке в обе таблицы:
Bash:
opendkim-genkey -b 2048 -d filehost.su -s mail -D /etc/opendkim/keys
mv /etc/opendkim/keys/mail.private /etc/opendkim/keys/filehost.su.private
mv /etc/opendkim/keys/mail.txt /etc/opendkim/keys/filehost.su.txt
chown opendkim:opendkim /etc/opendkim/keys/filehost.su.*
chmod 600 /etc/opendkim/keys/filehost.su.private
nano /etc/opendkim/keytable
Код:
mail._domainkey.filehost.su filehost.su:mail:/etc/opendkim/keys/filehost.su.private
nano /etc/opendkim/signingtable
Код:
*@filehost.su mail._domainkey.filehost.su
Bash:
systemctl restart opendkim
Плюс TXT-запись
mail._domainkey.filehost.su из свежего .txt-файла.cat /etc/opendkim/keys/filehost.su.txtТак же добавляю
4. Чего делать НЕ нужно. Новый сертификат не нужен: клиенты всех доменов подключаются к одному и тому же
mail.hosterum.ru — имя сервера с доменом ящика не связано. Кому принципиально брендированное imap.filehost.su — добавьте имя в certbot через --expand и CNAME, но я не вижу смысла плодить сущности. PTR тоже остаётся один, на имя сервера — принимающей стороне важно соответствие PTR↔A, а не совпадение с доменом письма.Нюанс: с несколькими доменами параметр
auth_default_domain в Dovecot теряет смысл — короткий логин неоднозначен. Логин полным адресом решает вопрос (у нас и так полным).Отдельная фича PostfixAdmin — домены-псевдонимы (alias domains): почта на
user@staroe-imya.org падает в user@hosterum.ru без создания ящиков. Карта virtual_alias_domain_maps под это уже подключена в main.cf — достаточно завести псевдоним в панели и прописать MX.При добавлении нового ящика в thunderbird может потребоваться ручная настройка: в поля «Имя сервера» вписывается FQDN сервера (
mail.hosterum.ru — тот, на который выписан сертификат), а не домен ящика; логин — полный адрес. IMAP 993/SSL, SMTP 465/SSL, метод аутентификации — «Обычный пароль».Антиспам штатными средствами Postfix
Ограничения в
nano /etc/postfix/main.cf выше уже отсекают основной мусор ещё до приёма письма — это самый дешёвый по ресурсам фильтр. Для ручного управления я добавляю классы и списки:
Код:
smtpd_restriction_classes = white_client_ip, black_client_ip, white_client, black_client
white_client_ip = check_client_access hash:/etc/postfix/lists/white_client_ip
black_client_ip = check_client_access hash:/etc/postfix/lists/black_client_ip
white_client = check_sender_access hash:/etc/postfix/lists/white_client
black_client = check_sender_access hash:/etc/postfix/lists/black_client
Bash:
mkdir /etc/postfix/lists && cd /etc/postfix/lists
touch white_client_ip black_client_ip white_client black_client
Формат — значение и действие:
Код:
# white_client_ip: пропускать всегда
203.0.113.50 OK
# black_client_ip
198.51.100.77 REJECT You are blacklisted
# white_client: адреса/домены отправителей
partner@legacy-server.ru OK
# black_client
annoying@example.net REJECT
Bash:
postmap /etc/postfix/lists/*
(и после каждого изменения — тоже). Классы вставляются в нужные restrictions сразу после permit_sasl_authenticated, белые — раньше чёрных:
nano /etc/postfix/main.cf
Код:
smtpd_sender_restrictions = permit_mynetworks,
permit_sasl_authenticated,
white_client,
black_client,
reject_non_fqdn_sender,
reject_unknown_sender_domain
Жёсткие проверки — включать с открытыми глазами:
- reject_unknown_helo_hostname и reject_unknown_client_hostname режут спам с завирусованных машин отлично, но под раздачу попадают и криво настроенные легитимные серверы (без PTR, с фантазийным HELO). Готовы разгребать «нам не доходит письмо от контрагента» и пополнять белый список — включайте.
- reject_unverified_sender (обратная проверка существования отправителя) — рабочая, но генерирует постоянную фоновую нагрузку и замусоривает лог. Я не использую.
На время обкатки любых новых ограничений я ставлю
soft_bounce = yes: все постоянные отказы 5xx превращаются во временные 4xx, отправители повторяют доставку, а по логу видно, кого зацепило, — и ничего не потеряно. Обкатал — убрал.Против осмысленного спама (а не ботов) штатных средств мало. Мой основной рецепт — Proxmox Mail Gateway перед сервером: бесплатный, фильтры из коробки, управление через веб. Но если вторая машина — роскошь, ниже два опциональных рецепта на этом же сервере: грейлистинг и SpamAssassin.
Опционально: грейлистинг (postgrey)
Идея грейлистинга: первому письму от незнакомой тройки «IP + отправитель + получатель» отвечаем временной ошибкой 450. Честный сервер повторит доставку через несколько минут — письмо пройдёт и тройка попадёт в белый список навсегда. Спам-боты в массе своей не ретраят. Режет спам на десятки процентов бесплатно и почти без CPU.
Bash:
apt install postgrey
Демон слушает 127.0.0.1:10023. Дефолтная задержка 300 секунд избыточна, в
/etc/default/postgrey я укорачиваю:по умолчанию заменим
Код:
POSTGREY_OPTS="--inet=10023"
на
Код:
POSTGREY_OPTS="--inet=127.0.0.1:10023 --delay=60"
Bash:
systemctl enable --now postgrey
Подключение — последним пунктом в smtpd_recipient_restrictions, чтобы дешёвые reject'ы отработали раньше и до postgrey доезжали только письма, которые мы в принципе готовы принять:
nano /etc/postfix/main.cf
Код:
smtpd_recipient_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_non_fqdn_recipient,
reject_unknown_recipient_domain,
reject_unauth_destination,
reject_unlisted_recipient,
reject_multi_recipient_bounce,
check_policy_service inet:127.0.0.1:10023
Пакет несёт приличный белый список крупных отправителей (
/etc/postgrey/whitelist_clients), свои добавляйте в whitelist_clients.local.Честно про цену вопроса: первое письмо от любого нового отправителя опаздывает — обычно на 1–15 минут, у криво настроенных серверов на часы. Хуже всего с одноразовыми письмами вроде кодов подтверждения: часть сервисов шлёт их с ротацией IP и не ретраит — код может не дойти вовсе. Именно из-за этого я грейлистинг включаю не везде: для корпоративной переписки — да, для ящиков, на которые регистрируются в сервисах, — подумайте дважды.
Опционально: SpamAssassin
Контентный фильтр для тех, кому мало отсева на уровне SMTP. Подключаю через spamass-milter — письмо сканируется до постановки в очередь, без городьбы с content_filter и повторной инъекцией:
Bash:
apt install spamassassin spamass-milter
Минимальный
/etc/spamassassin/local.cf:
Код:
required_score 5.0
rewrite_header Subject *****SPAM*****
report_safe 0
report_safe 0 важен: спам помечается заголовками (X-Spam-Flag: YES), а не заворачивается во вложение — иначе sieve и пользовательские фильтры работать не будут.В
/etc/default/spamass-milter раскомментирую блок для Postfix — сокет должен жить внутри chroot'а и принадлежать postfix:
Код:
SOCKET="/var/spool/postfix/spamass/spamass.sock"
SOCKETOWNER="postfix:postfix"
SOCKETMODE="0660"
OPTIONS="-u spamass-milter -I -i 127.0.0.1"
Два ключа здесь принципиальны:
-I — не сканировать письма аутентифицированных пользователей (исходящую почту своих людей проверять на спам и портить ей тему — вредительство), -i 127.0.0.1 — игнорировать localhost (Roundcube).
Bash:
systemctl enable --now spamd spamass-milter
(в старых релизах юнит SpamAssassin назывался spamassassin — проверяется через
systemctl list-units 'spam*'; обновление правил в свежих пакетах крутится таймером spamassassin-maintenance, смотрите systemctl list-timers).Подключаю к Postfix: в цепочку smtpd-милтеров — после OpenDKIM (сначала проверяем чужую подпись, потом считаем баллы), а в non_smtpd не добавляю — локально сгенерированную почту сканировать незачем:
nano /etc/postfix/main.cf
Код:
smtpd_milters = inet:127.0.0.1:8891, unix:spamass/spamass.sock
non_smtpd_milters = inet:127.0.0.1:8891
Осталось автоматически складывать размеченное в Junk. Глобальный sieve-скрипт, отрабатывающий до пользовательских правил:
Bash:
mkdir -p /etc/dovecot/sieve
cat > /etc/dovecot/sieve/spam-to-junk.sieve << 'EOF'
require ["fileinto"];
if header :contains "X-Spam-Flag" "YES" {
fileinto "Junk";
}
EOF
sievec /etc/dovecot/sieve/spam-to-junk.sieve
chmod 644 /etc/dovecot/sieve/spam-to-junk.*
И блок в dovecot.conf рядом с существующим sieve_script
nano /etc/dovecot/dovecot.conf
Код:
sieve_script spam {
type = before
path = /etc/dovecot/sieve/spam-to-junk.sieve
}
doveconf -n не должно быть ошибоки рестарт postfix и dovecot):
systemctl restart postfixsystemctl restart dovecotЦена вопроса: SpamAssassin — perl-демон, в памяти держит 200–300 МБ. На VPS с гигабайтом RAM я бы его не заводил — либо PMG на отдельной машине, либо посмотрите на rspamd, но это тема для отдельной статьи. Дообучение байеса (
sa-learn по папке Junk кроном) оставляю за кадром.Опционально: phpMyAdmin с доступом только с разрешённых IP
Выше я говорил, что в базу хожу консольным клиентом — это правда, но иногда phpMyAdmin экономит время: глянуть структуру таблиц PostfixAdmin, поправить опечатку в алиасе, выгрузить дамп одной кнопкой. Пакетный phpmyadmin из репозитория тянет Apache зависимостями и кладёт конфиги куда попало, поэтому ставлю руками с официального сайта — на момент написания актуальна 5.2.3 (важно: именно в ней закрыты депрекейты PHP 8.5, на младших релизах ветки будете разгребать warning'и), сверьтесь с downloads.
Bash:
cd /tmp
wget https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.gz
tar xzf phpMyAdmin-5.2.3-all-languages.tar.gz
mv phpMyAdmin-5.2.3-all-languages /var/www/phpmyadmin
mkdir /var/www/phpmyadmin/tmp
chown -R www-data:www-data /var/www/phpmyadmin
Конфиг
nano /var/www/phpmyadmin/config.inc.php:
PHP:
<?php
declare(strict_types=1);
$cfg['blowfish_secret'] = 'СЛУЧАЙНАЯ_СТРОКА_32_СИМВОЛА';
$cfg['TempDir'] = '/var/www/phpmyadmin/tmp';
$i = 0;
$i++;
$cfg['Servers'][$i]['auth_type'] = 'cookie';
$cfg['Servers'][$i]['host'] = 'localhost';
$cfg['Servers'][$i]['AllowNoPassword'] = false;
blowfish_secret — ровно 32 символа, генерируется так:
Bash:
openssl rand -base64 24
host = localhost — PHP пойдёт по unix-сокету, TCP тут не нужен. Технически учётка root@localhost в форму phpMyAdmin зайдёт: соединение локальное, запрет из mysql_secure_installation касался только удалённого входа. Но давать веб-морде root я смысла не вижу — завожу отдельного пользователя с правами ровно на те базы, в которые буду заглядывать:
SQL:
CREATE USER 'pma'@'localhost' IDENTIFIED BY 'X9mK4pLq';
GRANT ALL PRIVILEGES ON postfix.* TO 'pma'@'localhost';
GRANT ALL PRIVILEGES ON roundcube.* TO 'pma'@'localhost';
FLUSH PRIVILEGES;
Отдача через nginx: тот же вхост, два замка
Отдельный поддомен под phpMyAdmin — лишняя сущность: ещё одно имя в сертификате, ещё одна DNS-запись, и всё ради инструмента, которым пользуется один человек раз в месяц. Вешаю его локейшеном /pma/ на уже существующий вхост панели (mail.hosterum.ru) — там уже есть basic auth на весь server-блок, он автоматически накроет и phpMyAdmin. Сверху добавляю второй замок: доступ только с моего IP.
В
/etc/nginx/sites-available/padmin внутрь server {} добавляю блок — обязательно ВЫШЕ существующего location ~ \.php$:
Код:
location ^~ /pma/ {
allow 203.0.113.10; # мой домашний/офисный IP
deny all;
alias /var/www/phpmyadmin/;
index index.php;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
}
Три неочевидных места, на которых обычно спотыкаются:
^~в объявлении — приоритетный префикс: без него запрос /pma/index.php перехватит регексп \.php$ уровнем выше, SCRIPT_FILENAME соберётся от чужого root и вы получите 404 от php-fpm.- SCRIPT_FILENAME здесь —
$request_filename, а не привычный $document_root$fastcgi_script_name. С alias только эта переменная разворачивается в правильный путь /var/www/phpmyadmin/index.php; классическая пара дала бы /var/www/postfixadmin/public/pma/index.php, которого не существует. - allow/deny стоят до всего остального: nginx отвечает 403 ещё до передачи запроса в PHP — непрошеные гости не тратят ни воркер php-fpm, ни коннект к базе. Basic auth с server-уровня наследуется сам, отключать его внутри не нужно — получаем IP-фильтр И пароль одновременно.
Bash:
nginx -t && systemctl reload nginx
Захожу на https://mail.hosterum.ru/pma/ — сначала basic auth (padmin), затем форма phpMyAdmin (pma / пароль из CREATE USER). С любого другого IP — 403 ещё на пороге.
У кого IP динамический — деваться некуда, остаётся один basic auth (уберите строки allow/deny) либо доступ через VPN до сервера: WireGuard-интерфейс с адресом 10.0.0.1 и allow 10.0.0.0/24 — самый чистый вариант, но это тема отдельной статьи.
Fail2Ban: защита от перебора паролей
Через несколько часов после появления MX-записи к серверу придут боты и начнут методично перебирать пароли — sales@, admin@, info@ и далее по словарю. Fail2Ban читает логи, находит по regex строки неудачных аутентификаций и банит IP файрволом.
Bash:
apt install fail2ban
В Ubuntu 26.04 это ветка 1.1.x, из коробки включён только jail sshd (через jail.d/defaults-debian.conf). Свои настройки я кладу в
jail.local — jail.conf не трогаю, он перезапишется при обновлении пакета.Баню нативными nftables-действиями: iptables на 26.04 — прослойка-переводчик поверх nftables, ходить через неё смысла нет. Пользователям ufw —
banaction = ufw, всё остальное без изменений.nano /etc/fail2ban/jail.local:
Код:
[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports
backend = auto
ignoreip = 127.0.0.1/8 ::1 127.0.0.2
[sshd]
enabled = true
[postfix]
enabled = true
filter = postfix[mode=auth]
port = smtp,465,submission,imap,imaps,pop3,pop3s,4190
logpath = /var/log/mail.log
maxretry = 3
findtime = 1h
bantime = 1h
[dovecot-sql]
enabled = true
filter = dovecot-sql
port = smtp,465,submission,imap,imaps,pop3,pop3s,4190
logpath = /var/log/dovecot/info.log
maxretry = 3
findtime = 1h
bantime = 1h
[roundcube-auth]
enabled = true
port = http,https,smtp,465,submission,imap,imaps,pop3,pop3s,4190
logpath = /var/www/webmail/logs/userlogins.log
maxretry = 3
findtime = 1h
bantime = 1h
[recidive]
enabled = true
findtime = 1d
bantime = 1w
В ignoreip помимо loopback у меня внесён IP Zabbix-сервера (127.0.0.2 в примере): мониторинг стучится в почтовые порты без аутентификации, со стороны фильтров это выглядит как перебор, и без исключения вы забаните собственный мониторинг. Проходил.
Два принципиальных отличия от типовых мануалов:
1. filter = postfix[mode=auth] вместо самописного фильтра. У штатного фильтра postfix есть режимы: normal, auth, rbl, more, extra, aggressive. Строки вида
Код:
postfix/smtpd[...]: warning: unknown[203.0.113.66]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=sales@hosterum.ru
ловит именно режим auth — дефолтный normal их не видит, из-за чего половина интернета годами пишет собственные регулярки. Не надо: включите правильный режим. Хотите заодно банить мусорные коннекты (pipelining, кривой HELO) — mode=aggressive, но сначала прогоните его по своему логу.
2. port со всем почтовым набором в каждом jail'е. В классических конфигах action банит только smtp — бот, пойманный на 25-м порту, спокойно продолжает долбить 465, 993 и 995, и смысл бана стремится к нулю. Раз IP замечен в переборе — я закрываю ему почту целиком. Радикалы могут поставить в jail'ах
banaction = %(banaction_allports)s и рубить все порты разом.Фильтр для Dovecot 2.4: штатный мёртв, пишем свой
В типовых мануалах здесь включают джейл [dovecot] со штатным фильтром. Не тратьте время: на логах 2.4 он не ловит НИЧЕГО. Проверено на живом логе с реальным перебором — fail2ban-regex выдал 0 совпадений из 21510 строк, а бот, которого мой фильтр ниже поймал за 12 секунд, для штатного прошёл невидимкой. Причина — в 2.4 переписали формулировки: вместо «Aborted login (auth failed, ...)» демон теперь пишет «Login aborted: Connection closed (auth failed, 1 attempts in 2 secs) (auth_failed): ...», и регэкспы, писанные под 2.3, промахиваются мимо всех строк целиком. Поэтому джейла [dovecot] в моём jail.local просто нет — мёртвый груз.
Вместо него — строки auth-worker'а:
sql: unknown user и sql: Password mismatch. Через dovecot-auth проходит КАЖДАЯ проверка пароля — IMAP, POP3 и SASL-логины Postfix, — так что одна регулярка накрывает весь периметр аутентификации, и срабатывает она в самой ранней точке. Появляются эти строки при auth_verbose = yes: в нашем конфиге он включён не для красоты. Фильтр:nano /etc/fail2ban/filter.d/dovecot-sql.conf
Код:
[Definition]
failregex = ^.*auth-worker\(\S+,<HOST>\).+sql: (?:unknown user|Password mismatch)$
ignoreregex =
Важно:
<HOST> в регулярке обязателен — без него fail2ban откажется стартовать с ошибкой «No failure-id group». И следите за бэкслешами: в регэкспе они одинарные — \(, \S+, \). Задвоенный \\( коварен тем, что фильтр молча компилируется, jail стартует, а совпадений — ноль: искать литеральный бэкслеш в логе можно вечно. В перепечатках подобных фильтров по интернету <HOST> и бэкслеши регулярно съедаются или задваиваются движками сайтов — привет копипастерам, и себе самому тоже: после публикации скопируйте регэксп со страницы обратно и сверьте.Roundcube: перебор через веб-морду
Тонкость, о которой типовые мануалы молчат хором. Roundcube ходит в IMAP с localhost, поэтому при переборе пароля через веб-интерфейс Dovecot честно пишет rip=127.0.0.1 — а fail2ban такие находки демонстративно пропускает, в его логе появляется «Ignore 127.0.0.1 by ignoreself rule». И это правильно: ignoreself — защита от выстрела себе в ногу, бан собственного loopback положил бы разом и веб-почту, и SASL-связку Postfix↔Dovecot. Отключать его нельзя — атаку через веб надо ловить там, где виден настоящий адрес: в логе самого Roundcube.
Включаю журнал логинов — строка в
/var/www/webmail/config/config.inc.php:
PHP:
$config['log_logins'] = true;
С этого момента каждый вход, удачный и нет, пишется в
/var/www/webmail/logs/userlogins.log с реальным IP посетителя. Фильтр под этот формат в поставке fail2ban 1.1 уже есть — штатный roundcube-auth, джейл для него включён в jail.local выше: перебор через браузер банится по IP сразу и на веб-, и на почтовых портах. Проверить формат руками просто — один неверный вход в веб-морде и:
Bash:
tail -n3 /var/www/webmail/logs/userlogins.log
# Failed login for user@hosterum.ru from <реальный IP> in session ...
это надо сразу проверить
IP в строке должен быть ваш внешний, не 127.0.0.1 — Roundcube пишет адрес HTTP-клиента, nginx у нас отдаёт его в php-fpm напрямую. Регэксп-прогон — в общем блоке проверки ниже.
Для эстетов существует второй путь — научить сам Dovecot видеть реальный адрес веб-клиента: плагин dovecot_client_ip для Roundcube передаёт IP посетителя IMAP-командой ID, а
login_trusted_networks = 127.0.0.1 в dovecot.conf разрешает Dovecot ему верить. Тогда rip во всех логах становится настоящим, и веб-перебор ловится тем же dovecot-sql. Красиво, но это сторонний плагин на пути аутентификации — я обхожусь журналом Roundcube.Проверка фильтров до включения
Главная команда всего раздела. Формат лог-строк у Postfix и Dovecot имеет привычку меняться между версиями, поэтому любой фильтр я сначала прогоняю по реальному логу:
Bash:
fail2ban-regex /var/log/mail.log 'postfix[mode=auth]' --print-all-matched
fail2ban-regex /var/log/dovecot/info.log /etc/fail2ban/filter.d/dovecot-sql.conf
fail2ban-regex /var/www/webmail/logs/userlogins.log roundcube-auth
Перебор в логе есть, а совпадений ноль — фильтр не подходит под ваш формат строк; правьте regex, а не включайте вслепую. Ноль совпадений при чистом логе ничего не доказывает — сначала сгенерируйте неудачный вход (ниже в боевом тесте), потом делайте выводы. Любителям чистого journald: можно перевести postfix-jail на
backend = systemd и не ставить rsyslog, но учтите, что journalmatch штатного фильтра смотрит на юнит postfix.service, а на Debian/Ubuntu реальный юнит называется postfix@-.service — при нуле совпадений задавайте journalmatch в jail'е руками. Я с journalmatch не воюю: файл предсказуемее.Запуск и боевой тест
Bash:
systemctl enable --now fail2ban
fail2ban-client status
# Jail list: dovecot-sql, postfix, recidive, roundcube-auth, sshd
если строка выше покажет только sshd сделаю
systemctl restart fail2ban
Работа видна в
/var/log/fail2ban.log (строки Found → Ban). Jail recidive читает этот же лог и повторных клиентов банит на неделю по всем портам.Имитирую перебор с посторонней машины (не с сервера — тест с внешнего IP честнее). Первый заход — SMTP, проверяет jail postfix:
Bash:
telnet mail.hosterum.ru 25
ehlo test
AUTH LOGIN
YWRtaW4= # «admin» в base64
cGFzc3dvcmQ= # «password» в base64
# 535 5.7.8 Error: authentication failed
Три захода — и в fail2ban.log появляется
[postfix] Ban ..., а соединения с этого IP умирают на всех почтовых портах.Второй заход — IMAP, проверяет самописный dovecot-sql (с другого IP, первый уже в бане):
Bash:
openssl s_client -connect mail.hosterum.ru:993 -quiet
a LOGIN admin@hosterum.ru wrong_password
# a NO [AUTHENTICATIONFAILED] Authentication failed.
Три неверных пароля — в /var/log/dovecot/info.log должны появиться те самые строки
auth-worker ... sql: Password mismatch, а следом в fail2ban.log — [dovecot-sql] Ban .... Строки в логе есть, а бана нет — возвращайтесь к fail2ban-regex и правьте регэксп, а не крутите maxretry.Третий заход — веб-морда: три неверных пароля в форме Roundcube (тоже с постороннего IP — свой рабочий улетит в бан вместе с вами) — и в fail2ban.log появляется
[roundcube-auth] Ban .... Пока все три контура не отработали на живом переборе, считать их настроенными нельзя.Убедиться в банах можно через
nft list ruleset | grep -A5 f2b или цивилизованно:
Bash:
fail2ban-client status postfix
fail2ban-client status dovecot-sql
fail2ban-client status roundcube-auth
fail2ban-client set postfix unbanip 203.0.113.66 или так fail2ban-client unban 127.0.0.2
Про самобан. Три опечатки пароля — в почтовом клиенте или в веб-морде, неважно — и пользователь улетает в бан вместе со всей своей почтой на час. Либо объясните это людям заранее, либо держите unbanip под рукой. Свои админские IP можно внести в ignoreip; локальные сети пользователей — я бы не вносил: перебор из LAN тоже случается, и лучше о нём узнать от fail2ban, чем от последствий.
Опционально: доступ к панелям только с разрешённых IP
Basic auth на PostfixAdmin уже стоит — это обязательный минимум. Если админите с постоянного адреса, имеет смысл закрутить и его, и Roundcube жёстче. Подходы для них разные, потому что аудитория разная: в панель хожу я один, в веб-почту — все пользователи.
PostfixAdmin: IP-фильтр поверх basic auth
В
/etc/nginx/sites-available/padmin в начало server {} (рядом с auth_basic):
Код:
allow 203.0.113.10; # мой IP
allow 198.51.100.5; # запасной (офис/VPN)
deny all;
Bash:
nginx -t && systemctl reload nginx
Всё: пароль спрашивается только у тех, кто пришёл с разрешённого адреса, остальным — 403. Замок на замке. Единственное «но» — на этом же вхосте по договорённости выше живёт /pma/: его собственные allow/deny внутри локейшена продолжают работать независимо, server-уровневые добавляются к ним первыми. Конфликта нет, просто помните, что теперь фильтра два.
Roundcube: гибрид — пользователи ходят свободно, но можно и зажать
Закрывать веб-почту по IP целиком — значит отрезать пользователей с мобильных сетей, где адрес меняется на каждом переподключении. Но бывают инсталляции «только для своих» (семья, маленький офис со статикой) — тогда та же пара строк в server {} вхоста webmail решает вопрос. Для варианта посередине — «своим свободно, из недоверенных сетей блокируем» — у nginx есть модуль geo: список задаётся вне server {}, в
/etc/nginx/conf.d/webmail-acl.conf:
Код:
geo $webmail_allowed {
default 0;
203.0.113.10/32 1; # админ
198.51.100.0/24 1; # офисная сеть
}
И в server {} вхоста webmail — достаточно проверки на уровне server:
Код:
if ($webmail_allowed = 0) {
return 403;
}
if на уровне server с return — один из немногих легальных способов применения if в nginx, никакой магии location-контекста тут нет. Держать список отдельным файлом удобно: добавили сеть — reload — работает, вхост не трогается.
Честно про цену: любой IP-фильтр на веб-почте означает, что пользователь из отпуска без VPN почту не откроет. Я на публичных инсталляциях этим не пользуюсь — fail2ban с задачей отстрела перебора справляется, а удобство пользователей дороже. Для корпоративного контура со своим VPN — наоборот, обязательная мера.
SSH и файрвол: закрываем периметр
Почта работает, панели закрыты — остался сам сервер. Три меры: нестандартный порт SSH, запрет root-логина, файрвол со списком того, что торчит наружу. Порядок действий важен: сначала файрвол с правилом на НОВЫЙ порт, потом смена порта, потом проверка вторым сеансом — и только после этого закрытие старого. Нарушите порядок на удалённой машине — поедете в консоль хостера.
UFW: сначала правила, потом enable
nftables я использую напрямую в fail2ban, но руками таблицы писать — на любителя; ufw поверх nftables даёт человеческий синтаксис и не конфликтует с f2b (у того свои цепочки). Внимание пользователям ufw-actions в fail2ban: если вы в разделе про f2b выбрали banaction = ufw — всё уже согласовано; если оставили мои nftables-multiport — тоже работает, цепочки f2b обрабатываются до правил ufw.
Bash:
apt install ufw
# Политика по умолчанию: всё входящее — мимо
ufw default deny incoming
ufw default allow outgoing
# SSH: и текущий 22, и будущий 3022 — старый уберём после переезда
ufw allow 22/tcp comment 'SSH old - remove after migration'
ufw allow 3022/tcp comment 'SSH'
# Почта
ufw allow 25/tcp comment 'SMTP'
ufw allow 465/tcp comment 'SMTPS'
ufw allow 587/tcp comment 'Submission'
ufw allow 143/tcp comment 'IMAP'
ufw allow 993/tcp comment 'IMAPS'
ufw allow 110/tcp comment 'POP3'
ufw allow 995/tcp comment 'POP3S'
ufw allow 4190/tcp comment 'ManageSieve'
# Веб
ufw allow 80/tcp comment 'HTTP ACME + redirect'
ufw allow 443/tcp comment 'HTTPS'
ufw enable
ufw status numbered
Кто не оставил POP3 в protocols у Dovecot — строки 110/995 пропускайте. 4190 наружу нужен, только если фильтры правят не через Roundcube, — иначе тоже можно не открывать. SSH-порт при желании сразу зажимается по источнику:
ufw allow from 203.0.113.10 to any port 3022 proto tcp вместо открытого на весь мир — тогда fail2ban для sshd становится почти декоративным.SSH: порт и запрет root
Смена порта — не защита от целевой атаки (nmap найдёт за минуту), а санитария: журнал перестаёт распухать от ботов, долбящих 22 по словарю, и sshd-jail в fail2ban начинает ловить только тех, кто искал осознанно. В Ubuntu 26.04 правки кладу в отдельный файл, /etc/ssh/sshd_config не трогаю — переживёт обновления пакета:
Bash:
cat > /etc/ssh/sshd_config.d/10-hardening.conf << 'EOF'
Port 3022
PermitRootLogin no
PasswordAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
EOF
PermitRootLogin no подразумевает, что на сервере есть обычный пользователь с sudo — если работали под root, СНАЧАЛА заведите его и проверьте вход:
Bash:
adduser guru
usermod -aG sudo guru
PasswordAuthentication yes оставляю сознательно — переезд на ключи правильнее, но это отдельное действие: сгенерировали пару, положили в ~/.ssh/authorized_keys, проверили вход по ключу — и только потом PasswordAuthentication no. В одной куче с переносом порта делать это не советую: два изменения аутентификации разом — двойной шанс отпилить сук.
и перезапускаю
systemctl daemon-reloadsystemctl restart ssh.socketТеперь — критический шаг: НЕ ЗАКРЫВАЯ текущую сессию, открываю второй терминал:
Bash:
ssh -p 3022 guru@mail.hosterum.ru
sudo whoami # root — значит sudo работает
Зашло — убираю старый порт:
Bash:
ufw delete allow 22/tcp
И последний штрих — jail sshd в fail2ban про новый порт не знает, у него в дефолтах port = ssh (22). В /etc/fail2ban/jail.local правлю секцию:
Код:
[sshd]
enabled = true
port = 3022
Bash:
systemctl restart fail2ban
fail2ban-client status sshd
Проверка периметра снаружи
С посторонней машины:
Bash:
nmap -p 22,80,443,3022,25,465,587,143,993,3306,33060 mail.hosterum.ru
Правильная картина: 22 — closed/filtered, 3022 и почтово-веб-набор — open, 3306 и 33060 — filtered (они и так слушают только loopback, но файрвол теперь страхует и от будущих ошибок конфигурации базы).
Финальная проверка
Bash:
dig +short MX hosterum.ru
dig +short TXT hosterum.ru
dig +short TXT _dmarc.hosterum.ru
dig +short TXT mail._domainkey.hosterum.ru
host 192.145.97.212
openssl s_client -connect mail.hosterum.ru:993 -brief
openssl s_client -starttls smtp -connect mail.hosterum.ru:587 -brief
apt install swaks
swaks --to ваш_ящик@gmail.com --from admin@hosterum.ru --server 127.0.0.1
Затем прогоняю письмо через mail-tester.com и добиваюсь 10/10. В Gmail «Показать оригинал» должен показывать SPF: PASS, DKIM: PASS, DMARC: PASS. Попадание первых писем в спам при нулевой репутации свежего IP — норма, а не ошибка конфигурации: пара пометок «не спам» у получателей лечит это быстрее, чем любые пляски с настройками. Массовых рассылок с нового сервера не делайте — репутацию зарабатывают тишиной.
Порты, которые должны быть открыты на файрволе:
| Порт | Сервис |
|---|---|
| 25 | SMTP (приём почты от других серверов) |
| 465, 587 | отправка клиентами (smtps, submission) |
| 143, 993 | IMAP, IMAPS |
| 110, 995 | POP3, POP3S — если оставили pop3 в protocols |
| 4190 | managesieve; наружу нужен, только если фильтры правят не через Roundcube |
| 80, 443 | веб: панель, почта, ACME |
| 3022 | SSH — если перенесли порт из 22 в разделе про периметр (стандартный 22 закрыт) |
Итоги
Получился полноценный почтовый сервер на актуальном стеке: Postfix 3.10 принимает и шлёт, Dovecot 2.4 хранит и отдаёт, Percona Server держит учётки, PostfixAdmin управляет, Roundcube показывает, OpenDKIM подписывает, Fail2Ban отстреливает ботов по всем портам сразу, а периметр закрыт файрволом с SSH на нестандартном порту. Второй и десятый домен добавляются за пять минут, а при необходимости конструкция расширяется релеем, грейлистингом, SpamAssassin и phpMyAdmin — соответствующие опциональные разделы выше.
Главный вывод по Dovecot 2.4: конфиг действительно переписали с нуля, но новый синтаксис логичнее старого — SQL-запросы в одном файле с остальным, переменные читаемы, версионирование конфига ловит ошибки на старте, а не в проде. Один раз переболел — дальше жить проще.
Что стоит прикрутить следом: мониторинг (очередь Postfix и срок сертификата в Zabbix), бэкап /var/mail/vhosts вместе с дампом базы (раз уж стоит Percona — XtraBackup в помощь), и — если спам станет проблемой всерьёз — Proxmox Mail Gateway перед сервером.