У меня небольшой парк серверов: несколько VPS, на которых крутятся пет-проекты и пара небольших продуктов. Если открыть access-лог любого из них, видно одну и ту же картину: изо дня в день кто-то методично перебирает «вкусные» пути — /.env, /.git/config, /.aws/credentials, /wp-login.php, /phpmyadmin.

Это не таргетированная атака, а фоновый шум интернета: ботнеты сканируют подряд все IP в надежде наткнуться на забытый .env с паролем от базы или на открытый .git, из которого можно вытащить исходники. Проблема не в отдельном запросе, а в количестве. Сколько именно прилетает вам — видно одной командой:

bash
# сколько обращений к «вкусным» путям накопилось в access-логе
grep -cE '/\.(env|git|aws|ssh)|wp-login|phpmyadmin' /var/log/nginx/access.log

Честно скажу: на саму нагрузку мне почти всё равно. nginx отдаёт 404 по регулярке за микросекунды, и даже тысячи таких обращений в день не создают заметного расхода CPU. Бьёт другое:

  • Шум в мониторинге. Если на 404 настроены алерты (а это удобно — так ловятся битые ссылки и сломанные деплои), реальные 404 тонут в потоке /.env и /wp-login.php. Полезный сигнал теряется в мусоре.
  • Раздувание логов. Каждый такой запрос — строка в access-логе. За месяц набегают десятки-сотни мегабайт мусора, который надо хранить, ротировать и пролистывать глазами при разборе инцидентов.

Хочется, чтобы такой сканер не просто получал 404, а выпадал из обслуживания целиком после первого же запроса к заведомо вредному пути. Дальше — как я это сделал на связке nginx + fail2ban + nftables.

Почему «просто лимитировать 404» в nginx не работает

Первая мысль — ограничить тех, кто генерит много 404. Логика здравая: обычный пользователь почти не ловит 404, а сканер — только их и ловит. Кажется, что limit_req по числу 404 решил бы всё.

Но в nginx так не сделать, и причина архитектурная. Модули ngx_http_limit_req_module и ngx_http_limit_conn_module работают на фазе preaccess — то есть до того, как сформирован ответ. В момент, когда лимитер принимает решение, статуса «404» ещё не существует — он появится позже, на фазе content. Поэтому сказать «ограничь запросы, которые завершились 404» напрямую нельзя: лимитеру попросту нечего проверять.

Обходные пути есть — например, постфактум считать 404 из лога (той же связкой с fail2ban) или городить map и limit_req на конкретные паттерны URL. Но это сложнее и капризнее, чем нужно. Я пошёл другим путём, опираясь на одно простое наблюдение:

К /.env или /.git/config никогда не ходит легитимный клиент. Раз так — не нужно ничего усреднять и подбирать пороги. Достаточно одного попадания в ловушку.

Это переносит задачу из «лимитера по статусу ответа» в «honeypot + fail2ban»: nginx помечает подозрительные запросы, складывая IP в отдельный лог, а fail2ban читает этот лог и банит на уровне файрвола.

Идея: honeypot вместо лимитера

Схема целиком выглядит так:

  сканер
     │  GET /.env · /.git/config · /.aws/credentials · /.ssh/id_rsa
     ▼
  nginx · honeypot.conf — ловит «вкусные» пути
     │  └─ пишет хит (IP, время, путь) в honeypot.log, отдаёт 404
     ▼
  nginx · deny-dotfiles.conf — любой /.<dotfile>
     │  └─ просто 404, в honeypot.log не пишет
     ▼
  fail2ban — следит за honeypot.log, maxretry = 1
     │  └─ один запрос к ловушке = бан
     ▼
  nftables · table inet f2b-table → addr-set-nginx-honeypot
     │  └─ reject со всех портов
     ▼
  IP отрезан целиком: сутки, при рецидиве — дольше (до недели)

Получается два сниппета nginx и три файла fail2ban. Разберём по шагам.

Шаг 1. Лог: IP, время и запрос

Для бана fail2ban-у достаточно IP. Но в лог удобно положить ещё время и саму строку запроса — тогда сразу видно, к какому файлу ломились, а реальная временная метка избавляет fail2ban от повторных банов при пересканировании лога (почему — разберём в шаге 5).

Формат лога объявляется директивой log_format. Важный момент: log_format валиден только в контексте http {} — в nginx.conf или в подключённом из него файле вроде /etc/nginx/conf.d/*.conf. Положить его в server-сниппет нельзя, nginx не запустится. Поэтому формат заводим один раз глобально:

/etc/nginx/nginx.conf · внутри http { }
# IP, время и сам запрос — видно, к какому файлу была попытка доступа
log_format honeypot '$remote_addr - [$time_local] "$request"';

В итоге honeypot.log выглядит так — по одному хиту в строке (один IP повторяется, если бот перебирает несколько путей):

/var/log/nginx/honeypot.log
93.123.109.178 - [27/Jun/2026:03:39:10 +0200] "GET /.env HTTP/1.1"
35.229.131.157 - [27/Jun/2026:03:40:02 +0200] "GET /.git/config HTTP/1.1"
107.170.47.137 - [27/Jun/2026:03:41:55 +0200] "GET /.aws/credentials HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:08 +0200] "GET /.env.local HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:09 +0200] "GET /.env.prod HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:11 +0200] "GET /backup.sql HTTP/1.1"

Шаг 2. Ловушка — honeypot.conf

Теперь сам honeypot. Это location с регуляркой на заведомо вредные пути. Совпало — хит уходит в honeypot.log, клиенту — 404:

/etc/nginx/snippets/honeypot.conf
# Honeypot: логируем сканеров, лезущих в чувствительные пути, и отдаём 404.
# Без якоря $ на конце — ловит /.git/config, /.env.local и т.п.
# Подключать ДО deny-dotfiles.conf, чтобы хит успел уйти в honeypot.log.
location ~* ^/(\.env|\.git|\.aws|\.ssh|config\.php\.bak|backup\.sql) {
    access_log /var/log/nginx/honeypot.log honeypot;
    return 404;
}

Несколько важных деталей:

  • ~* — регистронезависимый матч, чтобы /.ENV и /.Git тоже попадали.
  • Нет якоря $ на конце — ^/\.env ловит и /.env, и /.env.local, и /.env.prod.
  • Кроме dot-файлов — пара «приманок», к которым легитимный клиент не ходит: config.php.bak, backup.sql. А вот wp-login.php, xmlrpc.php и phpmyadmin кладите в honeypot, только если сайт точно не на WordPress/phpMyAdmin — иначе забаните живых админов.
  • access_log … honeypot на уровне location переопределяет общий access-лог: эти хиты уходят только в honeypot.log и в основной лог не попадают. То есть из 404-алертов они исчезают сразу.

Шаг 3. Глушим остальные dot-файлы — deny-dotfiles.conf

Honeypot ловит конкретный список. Но dot-файлов больше: /.htaccess, /.htpasswd, /.svn, /.idea и прочие. Их я не баню (мало ли, чей-то кривой клиент), но доступ закрываю — отдаю 404 на любой путь с /., кроме /.well-known/ (его трогать нельзя — там живёт ACME-челлендж Let's Encrypt):

/etc/nginx/snippets/deny-dotfiles.conf
# Закрываем доступ к dot-файлам (.git, .env, .ssh и т.д.).
# Отдаём 404, а не 403, чтобы сканер не подтвердил существование файла.
# (?!well-known) — не трогаем /.well-known/acme-challenge/, иначе сломаем
# продление сертификатов Let's Encrypt по HTTP-01.
# Подключать ПОСЛЕ honeypot.conf (nginx берёт первый подошедший location).
location ~ /\.(?!well-known) {
    return 404;
}

Почему 404, а не 403. Главная причина — единообразие: пусть закрытое выглядит как несуществующее, чтобы поведение сервера не давало сканеру сигнала. Бонусом 404 не подтверждает, что файл есть (403 сказал бы «файл существует, но тебе нельзя») — хотя серьёзному сканеру эта разница почти безразлична: ему нужен 200 с содержимым, а не код ошибки.

Про шум в логах. В отличие от honeypot, у deny-dotfiles.conf своего access_log нет, поэтому его 404 по-прежнему пишутся в основной лог и могут дёргать алерты. Если хочется убрать и их — допишите в этот location строку access_log off; (и при желании log_not_found off;). Я обычно так и делаю — ровно ради тишины в 404.

Шаг 4. Подключаем сниппеты к сайтам

Оба сниппета включаются в каждый server {}. Порядок критичен: honeypot первым (он логирует и должен сработать раньше), deny-dotfiles следом:

/etc/nginx/sites-enabled/your-site · внутри server { }
# порядок важен: honeypot логирует, deny-dotfiles глушит остальное
include /etc/nginx/snippets/honeypot.conf;
include /etc/nginx/snippets/deny-dotfiles.conf;

nginx выбирает первый подошедший regex-location в порядке их объявления. Если включить deny-dotfiles раньше, его широкий /\. перехватит запрос к /.git/config и вернёт 404 до того, как honeypot успеет залогировать IP — и бана не случится. Поэтому honeypot всегда выше.

Та же логика порядка касается и других regex-location на сайте. Если у вас есть, скажем, location ~ \.php$ с fastcgi_pass, объявленный выше сниппетов, то приманки на .php (в агрессивном режиме это wp-login.php и xmlrpc.php) уйдут в PHP-обработчик мимо ловушки. Подключайте include со сниппетами в начале server {} — до location'ов приложения; проверить итоговый порядок можно через nginx -T.

Проверяем конфиг и перезагружаем nginx:

bash
sudo nginx -t && sudo systemctl reload nginx

Шаг 5. fail2ban: фильтр, jail и запуск

nginx теперь складывает IP сканеров в honeypot.log. Осталось научить fail2ban читать этот лог и банить.

Фильтр

В начале каждой строки — IP, дальше пробел и дефис. fail2ban достаёт из этого <HOST>, а дату [$time_local] распознаёт сам:

/etc/fail2ban/filter.d/nginx-honeypot.conf
[Definition]
failregex = ^<HOST> -
  • ^<HOST> -<HOST> это макрос fail2ban для IP, а ^… - привязывает его к началу строки (за IP идёт - ). Каждая строка лога = одно «нарушение», а при maxretry = 1 хватает первого.
  • datepattern не нужен: [$time_local] — стандартный формат nginx-лога, и fail2ban распознаёт дату сам. Будь лог без метки (datepattern = {NONE}), fail2ban считал бы временем записи момент её прочтения: при рестарте или ротации лога старые строки выглядели бы свежими нарушениями, и давно ушедшие сканеры ловили бы бан заново. С реальной меткой такого не происходит — строки старше окна findtime просто игнорируются.

Проверить фильтр на живом логе можно ещё до включения jail:

bash
sudo fail2ban-regex /var/log/nginx/honeypot.log \
    /etc/fail2ban/filter.d/nginx-honeypot.conf

В выводе должно быть Matched на все строки лога.

Jail: один запрос — и бан

Сам jail. Тут собрана вся политика бана:

/etc/fail2ban/jail.d/nginx-honeypot.conf
[nginx-honeypot]
enabled   = true
filter    = nginx-honeypot
logpath   = /var/log/nginx/honeypot.log
maxretry  = 1
bantime   = 86400
banaction = nftables-allports
bantime.increment = true
bantime.maxtime   = 1w
ignoreip  = 127.0.0.1/8 ::1 203.0.113.10
  • maxretry = 1 — одного попадания в ловушку достаточно. К этим путям не ходят случайно.
  • bantime = 86400 — базовый бан на сутки.
  • bantime.increment = true + bantime.maxtime = 1w — рецидивистам срок растёт экспоненциально, но не больше недели. Нужен fail2ban ≥ 0.11 — в актуальных Debian/Ubuntu он давно новее.
  • banaction = nftables-allports — бан через nftables, и сразу на все порты, а не только 80/443. Сканер уже спалился как вредонос — режем ему доступ ко всему хосту целиком.
  • ignoreip — whitelist (про него ниже).

Не забаньте сами себя: whitelist своего IP

Это важно, потому что nftables-allports рубит все порты, включая SSH. Если вы зайдёте проверить ловушку, открыв https://ваш-сервер/.env из браузера, fail2ban честно забанит ваш домашний IP — и вы потеряете и сайт, и SSH к серверу.

Поэтому свой постоянный IP добавляется в ignoreip — лучше глобально, для всех jail сразу, в jail.local:

/etc/fail2ban/jail.local
[DEFAULT]
banaction = nftables-allports
ignoreip  = 127.0.0.1/8 ::1 203.0.113.10

Здесь 203.0.113.10 — плейсхолдер (адрес из документационного диапазона 203.0.113.0/24); подставьте свой статический IP. Если адрес динамический, можно вписать подсеть провайдера или ходить на сервер через VPN с фиксированным выходным IP и whitelist-ить его.

Когда так делать не стоит: ложные срабатывания

Связка maxretry = 1 + nftables-allports намеренно тупая и злая: одно попадание — и IP отрезан целиком. Это оправдано ровно потому, что к /.env и /.git/config легитимный клиент не ходит никогда. Но есть обратная сторона — шаренные адреса. За одним IP может сидеть много живых людей: CGNAT мобильного оператора, корпоративный или университетский NAT. Один заражённый клиент в такой сети дёрнет /.env — и под бан на всех портах попадёт весь выходной адрес, а с ним и реальные пользователи.

На моих пет-проектах аудитория узкая и техническая, поэтому риск приемлем: вероятность, что за тем же мобильным IP сидит мой живой пользователь, исчезающе мала. Для массового публичного сервиса я бы так не делал — поднял бы maxretry, сократил bantime, банил бы только 80/443 вместо allports и оставил в honeypot лишь совсем однозначные пути. Прикиньте свою аудиторию до включения ловушки.

Установка и запуск

Ставим fail2ban (если ещё нет) и включаем автозапуск:

bash
sudo apt update && sudo apt install fail2ban
sudo systemctl enable --now fail2ban

После правок конфигов перечитываем fail2ban и смотрим статус нашего jail:

bash
sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-honeypot

Что в итоге попадает в nftables

banaction nftables-allports заводит отдельную таблицу f2b-table и держит в ней set с забаненными адресами. Через пару недель работы у меня там накопилось несколько десятков IP — вот реальный дамп:

sudo nft list table inet f2b-table — показать вывод
table inet f2b-table {
    set addr-set-nginx-honeypot {
        type ipv4_addr
        elements = { 20.46.171.227, 34.40.32.210,
                 34.95.251.166, 35.229.131.157,
                 38.225.230.76, 45.148.10.62,
                 45.148.10.64, 45.148.10.120,
                 45.148.10.244, 45.198.224.25,
                 46.159.161.221, 62.4.29.86,
                 62.238.17.239, 65.49.1.66,
                 77.83.39.42, 77.83.39.94,
                 77.83.39.197, 85.11.167.118,
                 91.92.42.182, 91.92.241.196,
                 93.123.109.178, 107.170.47.137,
                 130.12.180.196, 135.235.138.254,
                 163.7.11.69, 176.65.139.231,
                 176.65.139.233, 176.65.139.235,
                 176.65.139.236, 176.65.139.238,
                 176.65.139.239, 195.178.110.199,
                 195.178.110.246, 213.142.151.136,
                 213.209.159.113, 216.73.160.2,
                 216.73.160.6 }
    }

    set addr-set-sshd {
        type ipv4_addr
    }

    chain f2b-chain {
        type filter hook input priority filter - 1; policy accept;
        meta l4proto tcp ip saddr @addr-set-nginx-honeypot reject with icmp port-unreachable
        meta l4proto tcp ip saddr @addr-set-sshd reject with icmp port-unreachable
    }
}

Что здесь что:

  • set addr-set-nginx-honeypot — множество забаненных IP именно нашего jail. fail2ban добавляет и убирает адреса отсюда по мере банов и истечения сроков.
  • set addr-set-sshd — пустой set другого, sshd-jail (он тоже на nftables). Видно, что разные jail держат свои отдельные множества.
  • chain f2b-chain висит на хуке input с приоритетом filter - 1 — то есть чуть раньше обычного фильтра. Любой TCP-пакет с адресом из set отбивается через reject with icmp port-unreachable.

Полезные команды: статус и разбан

Посмотреть, кто сейчас в бане и сколько всего поймано:

bash
# статистика и список IP в нашем jail
sudo fail2ban-client status nginx-honeypot

# забаненные адреса на уровне файрвола
sudo nft list table inet f2b-table

Если нужно именно снять бан (а не почистить лог — чистка баны не убирает, fail2ban держит состояние в своей БД):

bash
# снять бан с конкретного IP
sudo fail2ban-client set nginx-honeypot unbanip 1.2.3.4

# снять все баны во всех jail сразу
sudo fail2ban-client unban --all

Грабли, на которые важно не наступить

  • Сначала whitelist, потом включение. nftables-allports банит все порты, включая SSH. Пропишите свой IP в ignoreip до того, как пойдёте проверять ловушку, иначе рискуете запереть сами себя снаружи.
  • ACME и /.well-known. Регулярка /\. матчит и /.well-known/acme-challenge/… — путь, по которому certbot подтверждает домен по HTTP-01. Именно поэтому в deny-dotfiles стоит (?!well-known): без него продление сертификата Let's Encrypt начнёт ловить 404. На DNS-01 проблемы нет.
  • log_format — только в http {}. В server-сниппет его не положить: nginx не запустится с ошибкой «log_format directive is not allowed here».
  • Порядок include важен. honeypot.conf строго выше deny-dotfiles.conf, иначе широкий /\. перехватит хит, и бана не будет.
  • Не вешайте honeypot на пути, куда ходят люди. При maxretry = 1 любой случайный запрос к пути из списка = мгновенный бан. Поэтому в ловушке только то, к чему легитимный клиент не обращается в принципе.
  • Ротация лога. honeypot.log лежит в /var/log/nginx/, поэтому его подхватывает штатный logrotate nginx (/var/log/nginx/*.log) — отдельно настраивать не нужно. fail2ban переживает ротацию сам.
  • За CDN/прокси — сначала real_ip. Если nginx стоит за Cloudflare или реверс-прокси, в $remote_addr попадёт адрес прокси, а не сканера — и fail2ban забанит ваш же прокси. Сначала настройте set_real_ip_from + real_ip_header, и только потом включайте ловушку.

Почему не CrowdSec, не WAF и не голый nftables

Справедливый вопрос: зачем собирать ловушку руками, если готовые инструменты уже есть? Пройдусь по тем, что рассматривал сам, — и объясню, почему в итоге остался на связке nginx + fail2ban + nftables.

CrowdSec. Идейный наследник fail2ban: агент разбирает логи по сценариям, баны применяют компоненты-баунсеры, а главный козырь — коллаборативные блоклисты. Адреса, засветившиеся у других участников сети, можно банить превентивно — ещё до того, как они постучатся к вам. Но для моей задачи это лишние движущиеся части: агент, локальный API, баунсер и свой формат сценариев — против трёх файлов конфига. К тому же fail2ban на серверах уже стоит и сторожит sshd: добавить ещё один jail дешевле, чем вводить новый стек. А вот если хочется репутационных списков из коробки — смотреть стоит именно на CrowdSec.

WAF или CDN перед сервером. Cloudflare с managed-правилами отсекает сканеров ещё до origin, и если весь трафик уже ходит через него — часть проблемы решается галочкой в панели. Останавливало меня другое: не все проекты живут за CDN, бан существует на чужой стороне, а прикрывает прокси только HTTP-трафик. Сканер, узнавший прямой IP сервера, продолжит долбиться мимо WAF, тогда как fail2ban с nftables-allports режет его на уровне хоста, на всех портах разом. Да, origin можно дополнительно закрыть файрволом по официальным диапазонам IP Cloudflare — но это ещё один кусок конфигурации, который надо поддерживать. И не забудьте грабли разделом выше: за прокси ловушке нужен настроенный real_ip, иначе первым забаненным окажется собственный CDN.

nftables без fail2ban. Сам файрвол умеет всё, что нужно для бана: set с timeout, добавление адресов на лету. Не хватает одного — процесса, который будет читать honeypot.log и складывать адреса в set. Стоит сесть за этот «маленький скрипт», как выясняется: ему нужно переживать ротацию лога, помнить баны между рестартами, уметь разбанивать и наращивать сроки рецидивистам. Поздравляю, вы пишете собственный fail2ban. Я предпочёл готовый — с базой состояния и двадцатью годами обкатки на чужих граблях.

Автоматизация: один скрипт на весь сетап

Всё описанное выше — два сниппета nginx и три файла fail2ban — я свернул в один идемпотентный скрипт: nginx-scanner-trap. Скрипт сам определяет ваш IP (чтобы не забанить вас же), спрашивает, какие ещё адреса добавить в whitelist, прописывает сниппеты во все сайты, ставит и настраивает fail2ban. Перед правкой делает бэкапы, а если nginx -t падает — откатывает изменения.

Безопасный путь — скачать, прочитать, запустить:

bash
curl -fsSLO https://raw.githubusercontent.com/gistrec/nginx-scanner-trap/main/setup-honeypot.sh
less setup-honeypot.sh          # читаем, что он делает
sudo bash setup-honeypot.sh     # интерактивно: подтвердит IP, спросит про whitelist

Сначала можно прогнать вхолостую — --dry-run покажет все действия, ничего не меняя. Агрессивные приманки (wp-login.php, xmlrpc.php, phpmyadmin) спрятаны за флагом --aggressive — включайте только если на сайте нет WordPress и phpMyAdmin. Если доверяете коду — есть и one-liner:

bash
curl -fsSL https://raw.githubusercontent.com/gistrec/nginx-scanner-trap/main/setup-honeypot.sh | sudo bash

В сумме это пара десятков строк конфига, которые заметно подчищают и логи, и — что для меня важнее — 404-алерты. Сканеры никуда не делись, но теперь первый же запрос к /.env отправляет бота в бан на сутки, а не превращается в сотню строк лога и алерт на ровном месте.