Что нового

Почтовый сервер на Ubuntu 26.04: Postfix, Dovecot 2.4 и Roundcube

Свой почтовый сервер в 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 + файрвол).

Содержание
DKIM


Исходные данные​


По тексту использую документационные значения — подставляйте свои:

ПараметрЗначение в примерах
Почтовый доменhosterum.ru
Имя сервера (FQDN)mail.hosterum.ru
Веб-почтаwebmail.hosterum.ru
Внешний IP192.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.ruA192.145.97.212
webmail.hosterum.ruA192.145.97.212
@ (hosterum.ru)MX 10mail.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/grub

cat /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
и еще две строки для работы на localhost
Код:
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 на выбор

Успех выглядит так:
1784900171041.png

Сертификат ляжет в /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 и пароль и вижу страницу

1784912669915.png


Все проверки должны быть зелёными. В форме 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';

обновляю страницу и внизу создаю администратора панели.

1784919272318.png


Setup password это пароль который задан в качестве хэша g6dDQC31 пишу его в это поле

Админ root@hosterum.ru
пароль для root@hosterum.ru у меня в этом поле zP3N12wY (у тебя свой)
пароль еще раз zP3N12wY

при успехе должен увидеть подтверждение по добавленному админу

1784919485717.png


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.pemssl_server_cert_file = /path/cert.pem
ssl_key = </path/key.pemssl_server_key_file = /path/key.pem
mail_location = maildir:/pathmail_driver = maildir + mail_path = /path
%d, %n, %u%{user|domain}, %{user|username}, %{user}
disable_plaintext_auth = yesauth_allow_cleartext = no
passdb { driver=sql; args=dovecot-sql.conf.ext }sql_driver + блок mysql {} + passdb sql {} с query прямо в конфиге
mail_plugins = $mail_plugins sievemail_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/roundcube

Sieve-фильтры и автоответчик​


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
Создаю TXT-запись mail._domainkey.hosterum.ru, в значение вставляю всё от v=DKIM1 до конца p=, убрав кавычки и переносы. Для 2048-битного ключа значение длиннее 255 байт — нормальные DNS-панели режут его на куски сами, это легально. Проверка:
1784973016910.png

apt install dnsutils - для использования команды dig
dig +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 жёстче, но его я включаю только когда уверен, что перечислил все источники.

Вот так это выглядит:

1784974221102.png


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"

1784974857402.png


Несколько доменов на одном сервере​


Вся конфигурация выше изначально мультидоменная — 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

Так же добавляю

1784977446399.png

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, метод аутентификации — «Обычный пароль».

1784978179829.png


Антиспам штатными средствами 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 postfix
systemctl 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-reload
systemctl 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 — норма, а не ошибка конфигурации: пара пометок «не спам» у получателей лечит это быстрее, чем любые пляски с настройками. Массовых рассылок с нового сервера не делайте — репутацию зарабатывают тишиной.

Порты, которые должны быть открыты на файрволе:

ПортСервис
25SMTP (приём почты от других серверов)
465, 587отправка клиентами (smtps, submission)
143, 993IMAP, IMAPS
110, 995POP3, POP3S — если оставили pop3 в protocols
4190managesieve; наружу нужен, только если фильтры правят не через Roundcube
80, 443веб: панель, почта, ACME
3022SSH — если перенесли порт из 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 перед сервером.
Об авторе
Guru
Василий, cистемный админ /gnu/linux/windows/macos/mikrotik/troubleshooter, создатель сайта
Интересуюсь всем что делает инфраструктуру быстрой и надёжной
Открыт к общению и проектам, написать мне можно через форму или в личном сообщении

❗ Если есть пожелания по обзору какого-либо вопроса не представленного на сайте - пиши в комментариях

Комментарии

Нет комментариев для отображения.

Информация о статье

Автор
Guru
Время чтения статьи
45 мин чтения
Просмотры
41
Посл. обновление

Ещё в Сервисы и системы

Ещё от Guru

Поделиться этой статьёй

Назад
Верх