fail2ban-археология: пять ловушек, годами молча копивших мёртвые баны
Пошёл смотреть в Grafana кто заходит на сайт — нашёл 1300 self-hits в день. Размотанный клубок: сломанный action, regex который банил host header, 1339 "забаненных" IP без iptables-rule. Доверяй но проверяй.
У меня на hs1 живёт небольшая инфра: портфолио-сайт, mail-сервер, GitLab, Grafana. Защита по классике — fail2ban, 11 jails. fail2ban-client status показывал бодрые цифры: тысячи Total banned, десятки Currently banned. Я был спокоен.
Пока не пошёл в Grafana посмотреть свежую аналитику.
Завязка: 1300 self-hits в день
Сижу, смотрю дашборд gmasich-seo-traffic: график pageviews ровный, как доска. Подозрительно ровный. Открываю Top User-Agents — на первой строке curl/8.7.1 с пустым referer. Стучится с 192.168.10.1 — это IP моего MikroTik gateway, то есть сам hs1.
Это оказался cross-monitor.sh — мой собственный скрипт, который раз в 3 минуты курлит https://gmasich.ru, mail.gmasich.ru, wtf.gmasich.ru и шлёт мне в Telegram, если что-то ответило не 2xx/3xx. Полезная штука. Но в pageview-метриках видна как 480 хитов в день.
Я уже добавлял в фильтр дашборда remote_addr != "192.168.10.1". Проблема в том, что cross-monitor живёт не на одном хосте. Лезу в infrastructure/monitoring.md:
Cross-server Monitoring установлен на: hs1, rg1, hs2
Три хоста. И они не курлят через WireGuard — резолвят DNS-имя gmasich.ru. То есть с rg1 запрос идёт через интернет → MikroTik → hs1, и в access.log светится публичный IP того хоста, а не 192.168.10.1. Проверяю — точно: 203.0.113.3 (rg1) и 203.0.113.2 (hs2) тоже сидят в логах.
OK, расширяю фильтр regex'ом на весь парк (плюс WG-mesh 10.99.0.0/24 для пар, которые могут ходить через туннель). Готовый снипет получился такой:
| remote_addr !~ "^(10[.]99[.]0[.][0-9]+|192[.]168[.]10[.]1|192[.]168[.]10[.]20|203[.]0[.]113[.]1|203[.]0[.]113[.]2|192[.]168[.]10[.]33|203[.]0[.]113[.]3|203[.]0[.]113[.]10|203[.]0[.]113[.]20)$"
Точки экранирую через [.], а не \\. — короче и не требует учетверённого экранирования при отправке в JSON через MCP update_dashboard.
И второе — переделываю все свои curl-скрипты на единый UA homelab-monitor/1.0. Теперь self-traffic в логах легко отличается, плюс этот UA не подпадает под filter fail2ban (ниже станет понятно зачем).
Ловушка 1: jail, который банит, но не блокирует
Раз уж полез в логи — добавлю в дашборд панель Top User-Agents. Что там сейчас стучит? Открываю и вижу:
| UA | Hits |
|---|---|
Mozilla/5.0 (ровно) | 17 |
ALittle Client | 14 |
Mozilla/4.0(compatible;MSIE8.0;...) (без пробелов) | 9 |
Все три — явные сканеры. Real Mozilla/5.0 имеет хвост (Windows NT 10.0...) Chrome/120.0. Real IE пишет Mozilla/4.0 (compatible;... с пробелом после ;.
Странно — у меня же есть jail nginx-user-agent, который должен это ловить. Иду смотреть:
$ sudo fail2ban-client status nginx-user-agent
Currently banned: 0
Total banned: 0
Ноль. За весь срок жизни. При том что filter ловит пачку UA-сигнатур (curl, python, libwww, zgrab, Scrapy, ...). Лезу в конфиг jail'а:
[nginx-user-agent]
enabled = true
filter = nginx-ua
maxretry = 1
bantime = 86400
action = ipset[name=blacklist, protocol=all]
ipset[name=blacklist]. Открываю action:
# /etc/fail2ban/action.d/ipset.conf
actionstart = ipset create blacklist hash:ip
actionban = ipset add blacklist <ip>
actionunban = ipset del blacklist <ip>
Кустарный action. Создаёт ipset, кладёт туда IP. И всё. Никакого iptables -m set --match-set blacklist src -j REJECT. То есть 1256 «забаненных» IP лежат в ipset, никем не используемом — References: 0 в ipset list.
Я это создал когда-то сам, явно с мыслью «потом добавлю iptables-rule». Не добавил. Прошло пару лет. fail2ban исправно копил IP, я думал что они блокируются.
Стандартный action вместо моего кастомного — iptables-ipset-proto6:
actionstart = ipset --create f2b-<name> hash:ip ...
iptables -I INPUT ... -m set --match-set f2b-<name> src -j REJECT
Этот и ipset создаёт, и iptables-rule вешает. Две команды, всё работает. Меняю action в jail'е:
action = iptables-ipset-proto6[name=nginx-ua, port="http,https", protocol=tcp, blocktype=REJECT]
port="http,https" + blocktype=REJECT — банить только web-порты, не generic. Если в filter случайно попадёт мой mail-proxy rg1 (а он бьёт через тот же curl), у меня хотя бы SMTP/IMAP/SSH не отвалятся.
fail2ban-client reload nginx-user-agent. Жду первого бана.
Ловушка 2: actionstart_on_demand
Час спустя смотрю — Currently banned: 0. ipset f2b-nginx-ua нет. iptables INPUT — нет правила. Но filter в логах срабатывает: [nginx-user-agent] Found 192.0.2.55.
Иду в /var/log/fail2ban.log ниже:
2026-05-07 10:02:36 fail2ban.actions ERROR Failed to execute ban jail 'nginx-user-agent'
action 'iptables-ipset-proto4-nginx-ua' info '...': Error starting action: 'Script error'
Action сам падает. Дальше в логе — конкретная команда:
exec: ipset --create f2b-nginx-ua maxelem 65536 iphash
{ iptables -w -C INPUT -p $proto --dport http,https -m set ... }
-p $proto — shell-переменная $proto в шаблоне action не разворачивается. И --dport http,https без -m multiport — синтаксически невалидно для нескольких портов. Это iptables-ipset-proto4 — старый шаблон для ipset v4, под современный Debian не подходит.
Меняю на iptables-ipset-proto6 (type = multiport, before = iptables-ipset.conf). Reload — всё ещё ipset не создан.
198.51.100.99 — TEST-NET-2 RFC, не реальный IP. Безопасно для тестов. Триггерю — ipset создаётся, iptables INPUT получает rule. Снимаю тестовый бан, action остаётся на месте.
Ловушка 3: regex банит host header вместо клиента
Час прошёл. Currently banned: 0. ipset пустой. Но в access.log полно строк, которые должны бы матчиться:
gmasich.ru 192.0.2.78 - [...] "GET /wp-login.php" 444 0 "-" "Mozilla/5.0"
203.0.113.1 192.0.2.55 - [...] "GET /" 444 0 "-" "Mozilla/5.0"
Первое поле — host header. На gmasich.ru это gmasich.ru. Когда сканер стучится прямо по IP, host берётся из IP, и nginx логирует первое поле как 203.0.113.1 (мой собственный hs1 public!).
Filter:
^<HOST> .*"(GET|POST|HEAD).* HTTP.*" .* "-" "(curl|python|...)..."
<HOST> — это placeholder fail2ban для IP. Он matches либо IP, либо DNS-имя (regex [\w\-.^_]*\w). И матчит первое поле, которое в nginx main log format — $host, а не $remote_addr.
То есть для строк выше fail2ban извлекает gmasich.ru (попытка зарезолвнуть DNS) или 203.0.113.1 (мой собственный IP — слава ignoreip, что не забанил себя). А реальные сканеры — 192.0.2.78, 192.0.2.55 — никогда не банились.
Fix:
^[^ ]+ <HOST> .*"(GET|POST|HEAD).* HTTP.*" .* "-" "(curl|python|...)..."
[^ ]+ — пропустить первое поле (vhost), <HOST> теперь матчит второе ($remote_addr). После reload — через 10 минут прилетел первый organic ban: Ban 192.0.2.55. ipset f2b-nginx-ua показывает 1 entry. iptables режет на 80/443.
Работает.
Ловушка 4: пять других jails в той же ситуации
Расширяю filter ещё четырьмя сигнатурами (-, Mozilla/5.0, ALittle Client, Mozilla/4.0(compatible; без пробела, BackupLand). Тестирую через fail2ban-regex на raw логе — Googlebot/Yandex/обычный Chrome не цепляются, грязные UA — да. Validation работает.
И тут думаю: а у меня же ещё пять jails с тем же broken action.
$ grep -l "ipset\[name=blacklist" /etc/fail2ban/jail.d/*.local
sshd.local
nginx-bad-requests.local
nginx-hiddenfiles.local
nginx-limit-req-2.local
wp-scan3.local
Sshd с этим тоже. Это значит SSH-баны на 90 дней — никогда не работали. Что-то 19 IP, что 1900 — mom не блокируется.
Перевожу все пять на iptables-ipset-proto6 с уникальным name= для каждого:
[sshd]
action = iptables-ipset-proto6[name=sshd, port="1221", protocol=tcp, blocktype=REJECT]
[nginx-bad-requests]
action = iptables-ipset-proto6[name=bad-requests, port="http,https", protocol=tcp, blocktype=REJECT]
# и т.д. для hiddenfiles, limit-req-2, wp-scan
Каждый jail получает свой f2b-<name> ipset и свой INPUT-rule. Старый общий blacklist ipset идёт под нож.
systemctl restart fail2ban (важно: не reload — кустарный action в зомби-состоянии не отпускался при простом reload, только полный restart). После старта fail2ban при инициализации читает /var/lib/fail2ban/fail2ban.sqlite3, table bips, и восстанавливает баны. Через свой новый action.
В итоге 1339 IP мигрировали из мёртвого blacklist в семь живых ipset-ов:
| Jail | Banned | ipset |
|---|---|---|
| sshd | 19 | f2b-sshd → 1221 |
| nginx-bad-requests | 0 | f2b-bad-requests → 80,443 |
| nginx-hiddenfiles | 13 | f2b-hiddenfiles → 80,443 |
| nginx-limit-req-2 | 10 | f2b-limit-req-2 → 80,443 |
| nginx-user-agent | 1 | f2b-nginx-ua → 80,443 |
| wp-scan3 | 1291 | f2b-wp-scan → 80,443 |
| recidive (новый) | 5 | f2b-recidive → 1221,80,443 |
recidive jail добавил отдельно — стандартный паттерн, ловит IP, который f2b банил maxretry=3 раза за 7 дней, и баним такого на 30 дней.
netfilter-persistent save — теперь iptables-rules выживут ребут.
Ловушка 5: bantime = -1 и что с ним делать
wp-scan3 — 1291 banned. С bantime = -1. Permanent.
Год назад я думал «WordPress-сканеры — нечего им жалеть». Логично. Но вот сейчас 1291 IP в perma-листе, накопленный с мая 2025-го. AWS/GCP/DO recycle'ят IP постоянно. Часть из этого списка уже принадлежит другим owner'ам.
Меняю bantime на 90 дней (7 776 000 сек). Reload jail.
И тут открытие: reload не пересчитывает existing bans. fail2ban при бане сохраняет endOfBan = startOfBan + bantime в момент создания ticket-а. Новые баны будут на 90 дней. Старые — навсегда. Менять policy задним числом нельзя без явного unban.
OK, чищу руками. Схема fail2ban.sqlite3:
CREATE TABLE bips(
ip TEXT NOT NULL,
jail TEXT NOT NULL,
timeofban INTEGER NOT NULL,
bantime INTEGER NOT NULL,
...
);
Запрос: «Все IP, забаненные больше 90 дней назад»:
SELECT ip FROM bips
WHERE jail='wp-scan3' AND timeofban < strftime('%s','now') - 7776000;
Распределение по возрасту:
| Возраст бана | Кол-во |
|---|---|
| < 90 дней | 530 |
| 90-180 дней | 402 |
| 180-365 дней | 359 |
Под чистку — 761 IP. Скрипт:
ssh hs1 '
cutoff=$(date +%s -d "90 days ago")
sudo sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
"SELECT ip FROM bips WHERE jail=\"wp-scan3\" AND timeofban < $cutoff" |
while read ip; do
sudo fail2ban-client set wp-scan3 unbanip "$ip" >/dev/null
done
sudo netfilter-persistent save
'
3 минуты, 761 unban'нено, 0 ошибок. Осталось 530 свежих. Ipset и iptables synced.
Бонус: почему конфиги серверов жили вне git
В процессе разбора заметил неприятную асимметрию: код приложения у меня в git, инфраструктурные доки в git, а конфиги сервисов на серверах — только на серверах. Каждое изменение /etc/fail2ban/jail.d/sshd.local существовало ровно в одной копии. Без истории, без diff'а, без отката.
Сделал в infrastructure/server-configs/ зеркало:
server-configs/
├── README.md, deploy.sh, .gitignore
├── hs1/
│ ├── etc/fail2ban/{jail.d/*.local, filter.d/nginx-ua.conf}
│ ├── usr/local/bin/check-hysteria.sh
│ └── root/notify_ssh_login.sh
├── hs2/
│ └── usr/local/bin/fail2ban-telegram.sh
└── shared/
└── opt/cross-monitor/cross-monitor.sh # деплоится на hs1+rg1+hs2
Структура зеркалит абсолютные пути на сервере. deploy.sh hs1 делает diff с прод (dry-run), APPLY=1 deploy.sh hs1 копирует через scp + sudo cp, бэкапит существующие в .bak-YYYYMMDD-HHMMSS. Workflow:
$EDITOR server-configs/hs1/etc/fail2ban/jail.d/sshd.local
./deploy.sh hs1 # diff
APPLY=1 ./deploy.sh hs1 # apply
ssh hs1 'sudo fail2ban-client reload'
git commit -am "fail2ban: ужесточить sshd"
Теперь история есть. И если в следующий раз я сделаю что-нибудь странное и сломаю prod — git revert.
Уроки
-
«Currently banned: 19» — не источник правды. Источник —
ipset list f2b-<name>(Number of entries) иiptables -S INPUT | grep f2b-(есть ли REJECT rule). Проверять оба. -
<HOST>в fail2ban regex — это первое поле, подходящее под IP-pattern. Для nginxmainlog format нужно^[^ ]+ <HOST>чтобы пропустить vhost. Иначе либо DNS lookup наgmasich.ru, либо самобан на public IP сервера. -
iptables-ipset-proto4сломан в современных дистрибутивах (kernel 3.0+, ipset v6+). Использоватьiptables-ipset-proto6. blocktype=REJECT сport=ограниченным — чтобы случайный бан не отстрелил всю сеть от того же IP. -
actionstart_on_demand=true— actionstart запускается при первом бане, не при reload. Для теста:fail2ban-client set <jail> banip 198.51.100.99(TEST-NET-2 RFC), потом проверитьipset list f2b-<name>. -
ignoreipjail-level переопределяет, не дополняет глобальный DEFAULT. При локальном override — копировать все исходные сетки (CF, LAN, etc.). -
reloadне пересчитывает existing bans при измененииbantime. Для очистки старых нужен явныйunbanip(через sqlite + цикл). -
Self-traffic в аналитике — не один cron, а вся инфра. Любой хост, который курлит ваше DNS-имя, попадает в логи с своим публичным IP. Считать всю сеть, а не отдельные машины.
-
Единый UA для всех своих скриптов (у меня —
homelab-monitor/1.0) — упрощает фильтрацию и делает self-traffic визуально отличимым в Grafana. И обходит свой же fail2ban-filter без специальных whitelist-исключений по пути. -
Серверные конфиги — в git. Через server-configs/ + deploy.sh, а не «правлю по месту». История + diff + откат.
-
«Доверяй, но проверяй». Особенно собственному коду годичной давности.
После всего разбора у меня осталось ощущение — основная работа security была не в том чтобы что-то новое настроить, а в том чтобы убедиться, что давно настроенное реально работает. Это, пожалуй, главный вывод.
Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.