Продвинутые jail'ы fail2ban для Postfix и Dovecot: точные regex, распределённый перебор и recidive-баны
Дефолтные postfix-sasl и dovecot ловят топорный перебор с одного IP и проваливаются на том, что реально бьёт по почте в 2026-м: медленная распределёнка с ботнетов, AUTH-зондирование и рецидивисты. Разбираем точные failregex, проверку через fail2ban-regex, recidive поверх всех jail'ов и ipset вместо тысяч правил iptables.
EvilMail Team25 июля 2026 г.13 мин чтения
Откройте /var/log/mail.log на любом сервере, который стоит в интернете хотя бы неделю, и вы увидите примерно это:
Четыре попытки, четыре разных IP. За час их набежит несколько тысяч, и почти каждый адрес засветится один-два раза, после чего исчезнет навсегда. Ваш
postfix-sasl
с
maxretry=3
и
findtime=10m
не увидит ни одной такой атаки: порог per-IP просто никогда не набирается. Это не сломанный fail2ban — это сломанная модель угроз. Дефолтные jail'ы писались под сценарий «один школьник с одного адреса долбит словарём», а бьёт по почте в 2026-м совсем другое: медленный распределённый перебор с ботнетов, AUTH-зондирование без единой успешной сессии и рецидивисты, возвращающиеся через час после снятия бана.
Тезис простой: банить надо не IP, а поведение. Порог maxretry=3 даёт ложное чувство защиты. Шум в логах реально снижают три вещи — узкие протестированные regex, агрегирующий recidive-jail поверх всех остальных и отсечение ботнета на уровне Postfix ещё до того, как fail2ban вообще что-то увидит.
Три класса угроз, которые дефолт не держит
Распределённый перебор. Тысячи IP, по 1-2 попытки на адрес. Per-IP порог бесполезен по определению — атака размазана так, чтобы ни один счётчик не сработал.
AUTH-зондирование. Бот проверяет, вообще ли жив SASL/IMAP-auth на порту, не пытаясь подобрать пароль. Одна попытка, разрыв, следующий IP. Успешных сессий ноль, а нагрузка на smtpd/imap-login вполне реальная.
Рецидив. IP забанили на час, бан снялся — и тот же адрес (или та же /24) вернулся. Дефолтный bantime=600 тут работает как будильник, а не как защита.
Ни один из этих сценариев не лечится подкручиванием maxretry в одном jail'е. Их ловит только композиция слоёв.
Анатомия строки лога: что именно матчим
Прежде чем писать regex, надо понять, за что он цепляется. Возьмём боевую строку Postfix:
Здесь unknown[203.0.113.7] — это hostname[ip], и именно IP в квадратных скобках fail2ban должен захватить как <HOST>. Обратите внимание на unknown: reverse DNS у ботнета обычно отсутствует, так что шаблон [-._\w]+\[ должен допускать, что имя хоста — это буквально слово unknown. Разные механизмы (SASL LOGIN, SASL PLAIN, SASL CRAM-MD5) дают разный суффикс, поэтому в failregex нужна альтернатива, а не жёсткий литерал LOGIN.
Ключевая ловушка: rip=203.0.113.7 — это remote IP, а lip= — local IP вашего же сервера. Матчить <HOST> надо строго по rip=, иначе при определённых форматах лога fail2ban забанит сам себя. И никогда не пишите rip=<HOST> без якорения — жадный regex может зацепить lip или значение, подставленное в user=<...>.
Точные failregex вместо жадных: тестируем через fail2ban-regex
Ни один фильтр не идёт в продакшн без прогона через fail2ban-regex. Это не опция, а обязательный шаг: регэксп, который «выглядит правильно», регулярно промахивается на реальном формате journald или ловит лишнее.
Читаем вывод: matched — сколько строк поймано, missed — сколько пропущено, и обязательно смотрим время выполнения. Если regex обрабатывает лог секундами вместо миллисекунд — у вас жадные .*, и это прямой путь к ReDoS: атакующий подсовывает специально сконструированную строку в поле, которое попадает в лог, и CPU уходит в потолок на бэктрекинге.
Рабочий failregex для SASL — узкий, с якорем и без единого жадного квантификатора:
Два правила: [^ ]+ вместо .* в опасных местах, \b для границ, якорь ^ в начале каждой альтернативы. Прогоните оба через fail2ban-regex на своём реальном логе, а не на примерах из статьи — формат зависит от версии Dovecot и от того, включён ли auth_verbose.
Кстати, про auth_verbose: без него Dovecot не логирует часть неудачных попыток, и целый пласт зондирования остаётся невидимым для fail2ban. В /etc/dovecot/conf.d/10-logging.conf:
auth_verbose = yes
Postfix: jail'ы за пределами SASL
SASL — не единственная поверхность. Ботнет вполне долбит вам RCPT с open-relay-зондами и HELO-флудом. Разводим jail'ы под разные фазы SMTP:
backend = systemd читает journald напрямую — если ваш Postfix и Dovecot пишут в journal, а не в /var/log/mail.log, rsyslog вам не нужен вообще. На системах с классическим syslog оставляйте backend = pyinotify и явный logpath.
Dovecot: аккуратный jail под IMAP/POP3/ManageSieve
Порты у почтового клиента разнообразные, и все они — поверхность атаки:
Здесь принципиально не опускать `maxretry` ниже 5 на клиентских портах. Живой пользователь на мобильном роуминге после смены пароля легко даёт 3-4 неудачных попытки подряд, пока Mail.app или Outlook переспрашивают учётку на каждом сокете. Порог 3 забанит вашего же клиента в аэропорту. Ботов вы всё равно добьёте recidive-слоем.
Борьба с распределённым перебором: когда per-IP порог не работает
Главный приём — отсечь ботнет до fail2ban, на уровне Postfix. DNSBL режет львиную долю мусора ещё на этапе RCPT, и эти IP вообще не доходят до счётчиков jail'а:
Обратите внимание на =127.0.0.[2..11] — это не косметика. Без явного ограничения return-кодов Postfix реагирует на любой ответ из подсети 127.0.0.0/8, включая сервисные коды Spamhaus 127.255.255.252–127.255.255.255 (запрос через публичный резолвер, превышение лимита). Итог печальный: при исчерпании бесплатной квоты Spamhaus начинает отдавать сервисный код вместо вердикта, ваше правило матчит его как «в блоклисте» — и сервер заворачивает всю входящую почту подряд. Диапазон 2..11 матчит только реальные листинги (SBL/CSS/XBL/PBL) и игнорирует коды ошибок.
Отсюда же вытекает оговорка про объём: публичный резолвер Spamhaus бесплатен лишь для небольшого потока запросов. При сколько-нибудь серьёзном трафике берите DQS-ключ или локальный rsync-фид, иначе за лимитом правило начинает жить своей жизнью.
Второй приём касается IPv6. Провайдеры выдают клиенту целую /64, поэтому забаненный /128 почти бесполезен: бот берёт следующий адрес из той же подсети. Проблема в том, что нативной агрегации до /64 в fail2ban нет — по умолчанию банится ровно тот адрес, что попал в лог. Лечится это кастомным action, который перед добавлением в сет маскирует <ip> до /64 (через sipcalc/ipcalc в actionban), либо nftables-сетом с интервалами вместо отдельных хостов. Держите это в голове при переезде на IPv6-only submission: без маскирования баны IPv6 работают вхолостую.
Наконец, включите растущие баны глобально — рецидив должен дорожать с каждым возвратом:
Geo/ASN-фильтрация Postfix-политикой (например, отсечение целых ASN известных ботнет-хостеров) снимает ещё существенную долю фона без единого бана — но это тема для отдельного разговора про policy-демоны.
Recidive: банить рецидивистов надолго
Вот где собирается вся конструкция. recidive — это jail, который читает не почтовый лог, а /var/log/fail2ban.log, и банит тех, кого другие jail'ы уже банили несколько раз. Он агрегирует поведение поверх всех слоёв.
maxretry=3 + findtime=1d: три бана за сутки в любом jail'е — и IP уезжает на неделю (604800). Для особо назойливых поднимайте до 2592000 (30 дней). Подводные камни:
recidive читает файл. Если у вас logtarget = SYSLOG или журнал уходит только в journald, файла /var/log/fail2ban.log нет, и recidive молча ничего не находит. Нужен logtarget = /var/log/fail2ban.log в fail2ban.local.
recidive не должен банить сам себя рекурсивно. У него отдельный banaction, а его собственные срабатывания отсекаются ignoreregex — иначе получите петлю.
Персистентность. Выставьте dbpurgeage не меньше самого длинного bantime (для недельных банов — минимум 604800, с запасом 648000) и включите bantime.increment, чтобы баны переживали рестарт fail2ban и росли с каждым возвратом: 1ч → 2ч → 4ч → ... до maxtime.
Проверка, что recidive живёт:
bash
fail2ban-client status recidive
# Currently banned: 1287
# Banned IP list: 203.0.113.7 198.51.100.88 ...
Действия и производительность: ipset, а не тысячи правил iptables
Когда в бане 5-20 тысяч адресов, banaction = iptables-multiport превращается в линейную цепочку: каждый пакет проходит по всем правилам сверху вниз, O(n). На 10k правил это заметная задержка на каждый пакет — почтовый сервер начинает тормозить на ровном месте.
Решение — iptables-ipset-proto6-allports или нативный nftables set: забаненные IP лежат в hash-таблице, поиск O(1) независимо от размера. Один matcher в цепочке вместо десяти тысяч правил.
bash
ipset list f2b-recidive | wc -l # сколько адресов в сете
Persistent ipset переживает рестарт сервиса, если сет сохраняется в /etc/ipset.conf и восстанавливается до старта fail2ban. На fail2ban поверх nftables (дефолт на свежих Debian/Ubuntu в 2026) сеты нативные — используйте banaction = nftables-allports. CrowdSec имеет смысл как дополнение: коллаборативный blocklist приносит IP, забаненные у других по всему миру, ещё до того как они постучались к вам. Но это надстройка, а не замена локальным jail'ам — специфику ваших логов знаете только вы.
Чек-лист внедрения
Протестируйте каждый фильтр через fail2ban-regex на реальном логе перед деплоем. Смотрите не только matched, но и время выполнения — медленный regex = ReDoS.
Якорьте failregex на ^, замените все .* на [^ ]+ или [^\]]+. Никаких жадных квантификаторов.
Dovecot: включите auth_verbose = yes, иначе часть попыток невидима.
maxretry на клиентских портах (993/995/587/4190) держите на 5, не ниже — не баньте живых людей на роуминге.
Отсекайте ботнет на уровне Postfix через reject_rbl_client с явным диапазоном return-кодов (=127.0.0.[2..11]) — иначе за лимитом Spamhaus рискуете завернуть всю почту.
IPv6: нативной агрегации до /64 нет — банить подсеть можно только кастомным action, который маскирует адрес перед добавлением в сет.
Включайте recidive последним, с logtarget = /var/log/fail2ban.log, maxretry=3/findtime=1d/bantime=1w+ и собственным banaction.
Перейдите на ipset/nftables set (iptables-ipset-proto6-allports) — при тысячах IP это разница между O(1) и деградацией latency.
Пропишите ignoreip = 127.0.0.1/8 ::1 плюс свои сети и мониторинг, и ignoreself = true — самобан на почтовом узле стоит дорого.
Мониторьте ipset list f2b-recidive | wc -l и fail2ban-client status <jail> — если сет растёт линейно и не стабилизируется, у вас дыра в фильтре или отсутствует DNSBL-слой.
Смысл всей конструкции в одном: отдельный jail ловит поведение в узком окне, а recidive поверх него ловит паттерн во времени. Первый снимает шум, второй — рецидив. Порог maxretry=3 в одном jail'е без этой второй петли не защищает вас — он просто чаще пишет в лог.