← § BLOG

fail2ban-археология: пять ловушек, годами молча копивших мёртвые баны

Пошёл смотреть в Grafana кто заходит на сайт — нашёл 1300 self-hits в день. Размотанный клубок: сломанный action, regex который банил host header, 1339 "забаненных" IP без iptables-rule. Доверяй но проверяй.

#fail2ban#security#nginx#monitoring#devops

У меня на 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. Что там сейчас стучит? Открываю и вижу:

UAHits
Mozilla/5.0 (ровно)17
ALittle Client14
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-ов:

JailBannedipset
sshd19f2b-sshd → 1221
nginx-bad-requests0f2b-bad-requests → 80,443
nginx-hiddenfiles13f2b-hiddenfiles → 80,443
nginx-limit-req-210f2b-limit-req-2 → 80,443
nginx-user-agent1f2b-nginx-ua → 80,443
wp-scan31291f2b-wp-scan → 80,443
recidive (новый)5f2b-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.

Уроки

  1. «Currently banned: 19» — не источник правды. Источник — ipset list f2b-<name> (Number of entries) и iptables -S INPUT | grep f2b- (есть ли REJECT rule). Проверять оба.

  2. <HOST> в fail2ban regex — это первое поле, подходящее под IP-pattern. Для nginx main log format нужно ^[^ ]+ <HOST> чтобы пропустить vhost. Иначе либо DNS lookup на gmasich.ru, либо самобан на public IP сервера.

  3. iptables-ipset-proto4 сломан в современных дистрибутивах (kernel 3.0+, ipset v6+). Использовать iptables-ipset-proto6. blocktype=REJECT с port= ограниченным — чтобы случайный бан не отстрелил всю сеть от того же IP.

  4. actionstart_on_demand=true — actionstart запускается при первом бане, не при reload. Для теста: fail2ban-client set <jail> banip 198.51.100.99 (TEST-NET-2 RFC), потом проверить ipset list f2b-<name>.

  5. ignoreip jail-level переопределяет, не дополняет глобальный DEFAULT. При локальном override — копировать все исходные сетки (CF, LAN, etc.).

  6. reload не пересчитывает existing bans при изменении bantime. Для очистки старых нужен явный unbanip (через sqlite + цикл).

  7. Self-traffic в аналитике — не один cron, а вся инфра. Любой хост, который курлит ваше DNS-имя, попадает в логи с своим публичным IP. Считать всю сеть, а не отдельные машины.

  8. Единый UA для всех своих скриптов (у меня — homelab-monitor/1.0) — упрощает фильтрацию и делает self-traffic визуально отличимым в Grafana. И обходит свой же fail2ban-filter без специальных whitelist-исключений по пути.

  9. Серверные конфиги — в git. Через server-configs/ + deploy.sh, а не «правлю по месту». История + diff + откат.

  10. «Доверяй, но проверяй». Особенно собственному коду годичной давности.

После всего разбора у меня осталось ощущение — основная работа security была не в том чтобы что-то новое настроить, а в том чтобы убедиться, что давно настроенное реально работает. Это, пожалуй, главный вывод.

Поделиться
TelegramX (Twitter)
Discussion

Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.

Открыть обсуждение на GitHub
fail2ban-археология: пять ловушек, годами молча копивших мёртвые баны · Григорий Масич