Привет, Хабр! Помните мою историю про Mac mini и Proxmox? Там я экспериментировал с железом. В этот раз интересная проблема пришла из сети, и пришла массово.

Проект из-за которого погрузился, не высоконагруженный, но коммерческий. И мне важно понимать, что сервер тратит ресурсы на клиентов, а не на сканеры.

В какой-то момент в отчетах появилась нетипичная динамика: всплеск визитов при нулевом росте конверсии. При анализе логов стало видно, что основная масса запросов приходит из регионов, не связанных с целевой аудиторией сайта.

  • Конверсия: 0%

  • Отказы: 99%

  • Цель визита: .env, wp-login.php, config.php и прочие «чёрные ходы».

Для проекта на Битриксе это:

  • лишняя нагрузка на PHP-FPM,

  • раздутые логи,

  • и риск, что под этим фоном пропустят реальную атаку.

Так началась эволюция моего собственного WAF.

Почему не внешний WAF или CDN?

Первый логичный вопрос, зачем писать своё решение, если есть готовые WAF и CDN?

Ответ в архитектурных приоритетах проекта.

  1. Контроль и прозрачность. Мне важно видеть, какой IP заблокирован, по какому правилу и на какой срок. Без «чёрного ящика» между мной и сервером.

  2. Блокировка до приложения. Любая защита на уровне PHP уже поздняя. Если бот дошёл до nginx или PHP-FPM, ресурсы уже потрачены.
    В моём случае блокировка происходит на уровне ядра (iptables + ipset), до передачи запроса приложению.

  3. Гибкость под специфику проекта. Типовые сигнатуры не всегда учитывают особенности конкретного Bitrix-проекта. Иногда нужно быстро добавить правило под конкретный паттерн  и сделать это за несколько минут.

  4. Простота архитектуры.  Проект локальный, без глобальной аудитории. Добавление внешнего слоя ради фонового сканирования, избыточное усложнение.

Важно, что решения принимаются на основе анализа HTTP-логов (L7), но сам дроп пакетов выполняется на уровне L3/L4 в ядре Linux. Это позволяет убрать мусор до nginx и PHP.

Я не противопоставляю этот подход внешним WAF. Для крупных или международных проектов CDN и облачные решения, абсолютно логичный выбор. В моём случае достаточно аккуратно выстроенной фильтрации на своей стороне.

Архитектура: эшелонированная оборона

Система получилась многоуровневой
Система получилась многоуровневой

Как это работает:

Первый рубеж (блокировка стран). Пакеты из стран, где у нас нет интересов, дропаются сразу. HTTP даже не парсится.

Второй рубеж (автоблокировка подозрительных IP). Если IP прошёл геоблок, но начал искать .env, .git или wp-login.php, он отправляется в бан на 2 часа.

Третий рубеж (черный список CrowdSec). Если IP уже «отметился» где-то в мире он может быть заблокирован превентивно.

Эволюция системы

Система не появилась в финальном виде. Это была последовательная доработка.

Этап

Архитектура

Управляемость

Риск ложных блокировок

Обслуживание

До v1

Разрозненные iptables-правила

Низкая

Повышенный

Ручные правки

v1

iptables + ipset + suspicious

Средняя

Контролируемый

Частичная автоматизация

v2

Модульная логика (country / suspicious)

Высокая

Ниже

Независимые модули

v2 + CrowdSec

Сигнатурный слой + кастом

Максимальная

Минимизирован

Автообновляемая база

Ключевое изменение во второй версии модули стали независимыми. Можно включать и отключать блокировку стран, подозрительные или CrowdSec отдельно. Это упростило отладку и снизило риск ложных блокировок, добавило больше управляемости.

Почему ipset - фундамент фильтрации

Если просто добавлять IP в iptables, то каждое правило  это отдельная строка в цепочке.

Когда приходит пакет, ядро проходит по правилам сверху вниз, пока не найдет совпадение. Если правил 10, это незаметно. Если правил 10 000, начинается линейный перебор.
То есть фактически:

  • больше IP → длиннее цепочка → больше сравнений

  • сложность проверки - O(n)

Под небольшой нагрузкой это может быть терпимо. Под волной сканирования уже нет.

ipset работает иначе. Вместо тысячи отдельных правил в iptables остаётся одно:

-m set --match-set blocked_ips src

А сами IP лежат внутри набора (hash:ip или hash:net). Проверка происходит через хэш-таблицу, а не перебор списка. Это значит:

  • 1 IP в наборе или 100 000, разницы почти нет

  • проверка выполняется за O(1)

  • всё происходит на уровне ядра

Именно поэтому ipset  база всей схемы. Без него она бы не масштабировалась.

В первых версиях  я обновлял наборы так:

ipset flush blocked_ips
ipset add blocked_ips

И очень быстро столкнулся с проблемой, что в этот момент сервер фактически открыт. Так как между flush и заполнением список пустой.

Решил это обновлением набора атомарно, через временный список и swap. Сначала создаем временный набор, далее наполняем его, выполняется ipset swap и удаляется старый набор.
Так как swap выполняется мгновенно на уровне ядра. Никакого промежутка с пустым списком нет. Фильтрация не прерывается мы радуемся.
Примерно это выглядит так:

ipset create "${country}_temp" hash:net 2>/dev/null || ipset flush "${country}_temp"
if [[ -s "$temp_file" ]]; then
    while read network; do
        ipset add "${country}_temp" "$network"
    done < "$temp_file"
    ipset swap "${country}_temp" "$country"
fi
ipset destroy "${country}_temp"

Ловим сканеров по намерению

auto-block-suspicious.sh не банит за обычные 404. Он ищет сомнительные намерения со стороны заходящего. Примеры паттернов по которым это определяю:

  • wp-login.php на проекте Битрикс

  • shell.php, shoha.php

  • .env, .git, .aws/credentials

Логи читаются через tail -n, а не перечитываются целиком. Скрипт сам определяет, где лежат access-логи или можно прописать путь до нужных в момент установки.

Логика работы
Логика работы

IP попадает в временный бан на 2 часа, затем автоматически удаляется. Реальный случай,  в начале этого года на сайт под управлением битрикс, пошла волна запросов к /wp-login.php. За несколько минут, более 200 попыток с десятков IP. После включения модуля проверки подозрительных  IP, адреса начали уходить в бан в течение 20–30 секунд, а нагрузка вернулась к нормальному уровню практически сразу.

=== Статистика системы блокировки ===
 Блокировки по странам:
  China: 8802 IP
  Всего заблокировано по странам: 8802 IP
Автоматическая блокировка подозрительных IP:
  Состояние: ✓ ВКЛЮЧЕН
  Всего: 10 IP
  Примеры заблокированных IP:
4.193.97.168 timeout 4972
195.178.110.109 timeout 3472
195.178.110.246 timeout 3472
144.91.93.174 timeout 4972
20.211.1.210 timeout 4972
130.12.180.34 timeout 4972
45.148.10.238 timeout 4972
195.178.110.199 timeout 4972
89.248.168.239 timeout 7071
94.26.88.31 timeout 4972

CrowdSec как базовый слой

CrowdSec подключён через bouncer, который добавляет IP в ipset crowdsec_blacklist с TTL.Таким образом:

  • глобальные сигнатуры закрываются автоматически,

  • решения синхронизируются,

  • бан снимается по таймауту без ручного вмешательства.

CrowdSec не заменяет кастомную логику. Он закрывает известные паттерны, а модули country и suspicious, проектную специфику. Это ещё один барьер в нашей обороне.

Метрики: что изменилось

Период сравнения: cентябрь–декабрь 2025, без защиты, январь–февраль 2026,после внедрения WAF

Показатель

Без защиты

С WAF

Мусорный трафик

~12%

< 0.3%

Средний LA

1.8–2.5

0.9–1.2

Отказы

До 40%

12–15%

Аналитика

Шум и боты

Чистые данные

Пики сканирования, доходившие до десятков RPS, теперь обрываются на уровне ядра и не доходят до nginx.

Пример графика из метрики для наглядности
Пример графика из метрики для наглядности

Наглядно видно что когда включается блокировка по странам, количество обращений резко снижается.
Где это решение не поможет: 

  • Не спасёт от атаки большим потоком трафика по сети (L4)

  • Не заменяет обновления Битрикса и других CMS

  • Не защищает от атак, которые идут через обычные домашние IP-адреса

  • Не нужен, если у вас глобальный проект с полноценным CDN

Это помогает отсеивать обычный мусор и мелкие атаки, но не заменяет полноценную безопасность (SOC).

Что дальше

Сейчас управление идёт через консоль:

Хочется сделать лёгкий дашборд:

  • карта заблокированных стран,

  • топ IP-агрессоров,

  • кнопка «помиловать».

Если у кого-то есть идеи по лёгкому UI-стеку для такой задачи, буду рад обсудить.

Заключение

В итоге получилась понятная и управляемая система фильтрации. Она не заменяет обновление Битрикса (других CMS) и закрытие дыр, но она убирает фоновый шум. Трафик проходит через несколько уровней, решения принимаются прозрачно, обновления выполняются атомарно, а вся логика остаётся под контролем. Сейчас сервер обрабатывает то, ради чего он запущен, реальные запросы пользователей.

Всю обвязку я выложил на GitHub. Там есть установщик, который настроит всё за вас.

Буду рад, если поделитесь своими паттернами для блокировки или расскажете, как вы выживаете в условиях локальных блокировок.