SMTP под лавиной: почему anvil вас не спасёт и как выстроить оборону по эшелонам
Anvil — это не защита от DDoS, а счётчик, который выносит вердикт уже после accept() и после того, как процесс smtpd занял слот из пула. Разбираем арифметику исчерпания (50 из 100 слотов одному IP по дефолту) и строим эшелонированную оборону: nftables в ядре, postscreen на порту 25, anvil для жадных клиентов и fail2ban для рецидивистов.
EvilMail Team26 июля 2026 г.12 мин чтения
Порт 25 забит. postqueue -p показывает пустую очередь, доставка не отстаёт, но новые письма перестали приниматься. В journalctl -u postfix растёт стена одинаковых строк:
text
postfix/smtpd[21877]: warning: 203.0.113.9: too many connections
postfix/anvil[21880]: statistics: max connection count 10 for (smtp:203.0.113.9)
Ни одного спам-письма в очереди, а сервер лежит. Проблема не в контенте и не в фильтрах — вы исчерпали пул процессов. И самое неприятное: чтобы это устроить, атакующему не нужен ботнет. При дефолтной конфигурации Postfix один хост легально забирает половину вашего пула smtpd и наполовину вешает приём почты. Разберём, почему так, почему anvil тут не помощник и как выстроить оборону, в которой каждый рубеж дешевле предыдущего.
Что на самом деле делает anvil (и чего не делает)
Защита SMTP от DDoS и лавины соединений: anvil, postscreen, nftables — EvilMail Blog
Anvil — отдельный демон, ровно один на всю систему, который ведёт учёт в памяти по ключу «сервис : клиентский IP». Он считает число одновременных соединений, rate новых соединений, rate сообщений, получателей и — отдельно — rate новых TLS-сессий. Каждый процесс smtpd при новом событии ходит к anvil и спрашивает: «этому IP ещё можно?».
Ключевое слово здесь — при новом событии. Соединение уже принято ядром через accept(), master уже форкнул полноценный процесс smtpd, этот процесс уже занял слот из пула и съел свои мегабайты RAM — и только теперь он идёт к anvil за вердиктом. Anvil работает *после* того, как соединение стало вам дорого. Это soft-limit для честных, но шумных клиентов, а не firewall.
Окно счётчиков задаётся anvil_rate_time_unit (по умолчанию 60 секунд), скользящее. Раз в anvil_status_update_time (по умолчанию 600 секунд) anvil выплёвывает в лог статистику пиков — те самые строки statistics: max connection count. Их удобно читать постфактум, чтобы понять, кто вас душил, но в момент атаки они уже опоздали.
Вывод, который стоит принять сразу: anvil = наблюдаемость плюс вежливое торможение легитимных клиентов. Это не защита от DDoS. Всё, что он умеет ответить флудеру, — временная ошибка 4xx, а флудер по определению вернётся.
Арифметика исчерпания: один хост против всего пула
Вот цифры, из-за которых всё ломается. default_process_limit = 100 означает максимум около 100 параллельных процессов smtpd. Каждое входящее соединение — это отдельный процесс: слот в пуле плюс RAM. А smtpd_client_connection_count_limit по умолчанию равен 50.
Считаем. Один IP имеет право держать 50 одновременных соединений. Это 50 из 100 слотов — половина вашей ёмкости приёма, отданная одному хосту совершенно легально, без нарушения каких-либо лимитов. Два таких хоста — и пул исчерпан, третий клиент (в том числе Gmail с реальным письмом) упирается в стену. Никакого спама, никакого содержимого — чистое исчерпание ресурса.
Что реально тюнить:
Снизить smtpd_client_connection_count_limit до 10. Легитимному внешнему MTA десять параллельных соединений не нужны никогда.
Поднять default_process_limit до 200–400, если позволяет память. Прикидка: один smtpd съедает примерно 2–4 МБ, значит 300 процессов — это до ~1 ГБ на пике. Считайте под доступную RAM, не наугад.
Обязательно вынести свои сети в smtpd_client_event_limit_exceptions. По умолчанию туда попадает $mynetworks, но если ваш submission-релей, бэкенд рассылок или внутренняя интеграция ходят с адреса вне mynetworks — они первыми упрутся в свежий count_limit=10, и вы положите себя сами.
Боевой main.cf для anvil
Настроив anvil правильно, вы не остановите DDoS, но перестанете дарить пул одному клиенту и получите честные счётчики. Вот рабочий блок:
ini
# --- Лимиты per-client (обрабатывает anvil) ---
anvil_rate_time_unit = 60s
default_process_limit = 300
smtpd_client_connection_count_limit = 10
smtpd_client_connection_rate_limit = 30
smtpd_client_message_rate_limit = 100
smtpd_client_recipient_rate_limit = 200
smtpd_client_new_tls_session_rate_limit = 10
# Свои сети и loopback не лимитируем никогда
smtpd_client_event_limit_exceptions = $mynetworks 127.0.0.0/8 [::1]/128
Что здесь важно по пунктам:
connection_count_limit = 10 — потолок одновременных соединений на IP. Именно он отвязывает вас от арифметики 50/100.
connection_rate_limit = 30 — не более 30 новых соединений с IP за 60 секунд. Ловит того, кто быстро открывает и закрывает.
message_rate / recipient_rate — защита от быстрого прогона писем и от rcpt-флуда (перебор адресов).
new_tls_session_rate_limit = 10 — недооценённый параметр. TLS-хендшейк дорог по CPU, и его флудят отдельно от писем: открыть соединение, начать STARTTLS, бросить. Ограничивать rate новых TLS-сессий стоит явно.
Клиент, превысивший лимит, увидит одно из:
text
421 4.7.0 Error: too many connections
450 4.7.1 Error: too many connections from 203.0.113.9
Оба — 4xx, временные. По RFC клиент обязан повторить позже. Для честного MTA это правильно: он переставит письмо в очередь и вернётся через минуту. Для флудера это ничего не решает — он и так собирался вернуться. Поэтому anvil тормозит, но не отсекает.
Разные лимиты на разные сервисы вешаются в master.cf через -o. Порт 25 держим строгим, submission (587) — мягче: там аутентифицированные клиенты, которым нормально держать больше соединений.
ini
submission inet n - y - - smtpd
-o smtpd_client_connection_count_limit=40
-o smtpd_client_event_limit_exceptions=$mynetworks
Воронка обороны: где что режется
Прежде чем идти вниз по стеку, зафиксируем общую картину. Оборона работает только тогда, когда каждый следующий рубеж дороже предыдущего, а самый дешёвый стоит первым.
postscreen: дешёвый рубеж перед smtpd
postscreen — это один процесс на весь порт 25. Вместо того чтобы форкать тяжёлый smtpd на каждое входящее соединение, он держит «толпу» лёгких соединений сам, прогоняет их через DNSBL и тесты протокола и только прошедших передаёт (pass) настоящему smtpd. Именно этим он ломает экономику флуда: зомби-ботнету теперь противостоит один дешёвый процесс, а не сотня дорогих.
master.cf:
ini
smtp inet n - y - 1 postscreen
smtpd pass - - y - - smtpd
dnsblog unix - - y - 0 dnsblog
tlsproxy unix - - y - 0 tlsproxy
DNSBL здесь взвешенные: Spamhaus zen даёт 3 очка, spamcop — 2, порог срабатывания — 2. greet_action = enforce включает pregreet-тест: postscreen «молчит» лишние доли секунды, и хамоватые боты, которые начинают слать команды не дождавшись баннера, отсекаются до рукопожатия. postscreen_cache_map на диске означает, что уже проверенные хорошие клиенты не гоняются через тесты повторно.
Критично: postscreen вешают только на порт 25. Никогда не на submission 587/465 — там сидят ваши аутентифицированные пользователи, и тесты протокола сломают их клиенты. В логе postscreen читается так:
text
postscreen[3120]: PASS NEW [198.51.100.7]:52104
postscreen[3120]: PASS OLD [198.51.100.7]:52233
postscreen[3120]: NOQUEUE: reject: RCPT from [203.0.113.9]:41922:
550 5.7.1 Service unavailable; client [203.0.113.9] blocked using zen.spamhaus.org
Сбросить лавину в ядре: nftables до Postfix
SYN-флуд и тысячи полуоткрытых соединений нельзя пускать даже до postscreen — их место в netfilter, где процесс вообще не создаётся. Ключевой момент синтаксиса: чтобы лимит считался на каждый исходный IP, а не глобально на весь порт, ct count и limit rate заворачивают в meter { ip saddr ... }. Без meter вы ограничите суммарный трафик порта 25 и сами себе устроите отказ на первом же всплеске.
nft
table inet filter {
set mx_whitelist {
type ipv4_addr
elements = { 198.51.100.10, 192.0.2.25 }
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
tcp dport 25 ip saddr @mx_whitelist accept
tcp dport 25 ct state new meter conn25 { ip saddr ct count over 20 } \
reject with tcp reset
tcp dport 25 ct state new meter rate25 { ip saddr limit rate over 15/minute } \
reject with tcp reset
tcp dport 25 accept
}
}
На legacy iptables то же самое — per-IP лимит задаётся через connlimit-mask:
bash
iptables -A INPUT -p tcp --syn --dport 25 \
-m connlimit --connlimit-above 20 --connlimit-mask 32 \
-j REJECT --reject-with tcp-reset
# rate — через -m hashlimit с --hashlimit-mode srcip
Две оговорки, без которых вы прострелите себе ногу:
Whitelist для своих. MX-партнёры, вторичные релеи, мониторинг, бэкапный MTA — в mx_whitelist, иначе первый же всплеск легитимного трафика прилетит по своим.
NAT и крупные провайдеры. За одним IP Gmail или корпоративного шлюза сидят тысячи легитимных отправителей. Лимит /32 их порежет. Для таких источников считайте по подсети: --connlimit-mask 24 вместо 32 (в nftables — ip saddr and 255.255.255.0). Строгий /32 — только для явно враждебных диапазонов.
fail2ban: вынос рецидивистов на уровень ядра
Anvil и postscreen ловят одного и того же флудера заново при каждом соединении — это повторная работа. fail2ban читает лог, и как только IP набрал лимит нарушений, банит его прямо в nftables на N минут. Дальше ядро роняет пакеты этого IP молча, Postfix о нём даже не узнаёт.
[Definition]
failregex = postfix/postscreen\[\d+\]:.*\[<HOST>\].*blocked using
postfix/smtpd\[\d+\]: warning: <HOST>: too many connections
postfix/anvil\[\d+\]: statistics: max connection rate \d+/\d+s for \(smtp:<HOST>\)
Десять срабатываний за 10 минут — час бана в ядре. Самый надёжный триггер здесь — warning too many connections от smtpd; anvil-статистику пишут лишь раз в 600 секунд, так что как единственный признак она бесполезна, но хорошим дополнением к правилу служит. Рецидивист уходит с самого дорогого рубежа на самый дешёвый.
Как понять, что вас правда флудят
Прежде чем крутить лимиты, отличите настоящий DDoS от одного жадного, но легитимного клиента — забытого cron, сломанной интеграции, которая долбит в цикле. Разница видна в распределении по IP:
bash
# Сколько соединений висит в SYN-RECV на порту 25
ss -tan state syn-recv 'sport = :25' | wc -l
# Отказы по лимитам за последние 10 минут
journalctl -u postfix --since '10 min ago' | grep 'too many connections'
# Пики per-IP по данным anvil
grep 'postfix/anvil' /var/log/mail.log | grep 'connection count'
# Топ клиентов по числу открытых соединений
ss -tan 'sport = :25' | awk '{print $5}' | cut -d: -f1 \
| sort | uniq -c | sort -rn | head
# Проверить реальный дефолт на своей сборке
postconf -d default_process_limit
Если в топе один-два IP — это, скорее всего, ваш же кривой отправитель, лечится whitelist-ом и разговором с владельцем. Если хвост из сотен адресов, каждый по чуть-чуть, — это ботнет, и работает связка nftables + postscreen, а не anvil.
Как anvil считает соединения в окне
Одна путаница стоит того, чтобы снять её на картинке: count — это одновременные соединения прямо сейчас, а rate — накопление новых за скользящее окно anvil_rate_time_unit. Это разные лимиты, и превышают их по-разному.
Чеклист боевой готовности
smtpd_client_connection_count_limit = 10, rate-лимиты выставлены осмысленно, а не по дефолту.
$mynetworks и loopback в smtpd_client_event_limit_exceptions; свой submission/бэкенд гарантированно под исключением.
default_process_limit поднят под реальную RAM (≈2–4 МБ на процесс, считайте пик).
postscreen висит на порту 25 с DNSBL, greet-тестом и дисковым кешем — и не на 587/465.
nftables: meter { ip saddr ct count } + limit rate на dport 25, whitelist для MX и мониторинга, лимиты по /24 для NAT-провайдеров.
fail2ban jail активен, фильтр ловит и postscreen blocked, и smtpd too many connections.
postscreen_cache_map на диске, чтобы хорошие клиенты не перепроверялись.
В дашборде — счётчик syn-recv на :25 и статистика anvil; вы видите лавину до того, как её увидят пользователи.
Оборона держится ровно до тех пор, пока порядок рубежей соблюдён: лавину рубит ядро, толпу держит один postscreen, жадных тормозит anvil, рецидивистов выносит fail2ban обратно в ядро. Anvil в этой цепочке — не щит, а последний и самый дорогой доводчик, поэтому и трогаем его последним. На приёмном контуре evilmail.pro входящий порт 25 устроен ровно по этой воронке: до smtpd доходит лишь то, что уже прошло ядро и postscreen, и именно поэтому пул процессов остаётся свободным для настоящей почты, а не для чьей-то лавины SYN.