У меня небольшой парк серверов: несколько VPS, на которых крутятся пет-проекты и пара небольших продуктов. Если открыть access-лог любого из них, видно одну и ту же картину: изо дня в день кто-то методично перебирает «вкусные» пути — /.env, /.git/config, /.aws/credentials, /wp-login.php, /phpmyadmin.
Это не таргетированная атака, а фоновый шум интернета: ботнеты сканируют подряд все IP в надежде наткнуться на забытый .env с паролем от базы или на открытый .git, из которого можно вытащить исходники. Проблема не в отдельном запросе, а в количестве. Сколько именно прилетает вам — видно одной командой:
# сколько обращений к «вкусным» путям накопилось в 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 не запустится. Поэтому формат заводим один раз глобально:
# IP, время и сам запрос — видно, к какому файлу была попытка доступа
log_format honeypot '$remote_addr - [$time_local] "$request"';В итоге honeypot.log выглядит так — по одному хиту в строке (один IP повторяется, если бот перебирает несколько путей):
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:
# 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):
# Закрываем доступ к 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 следом:
# порядок важен: 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:
sudo nginx -t && sudo systemctl reload nginxШаг 5. fail2ban: фильтр, jail и запуск
nginx теперь складывает IP сканеров в honeypot.log. Осталось научить fail2ban читать этот лог и банить.
Фильтр
В начале каждой строки — IP, дальше пробел и дефис. fail2ban достаёт из этого <HOST>, а дату [$time_local] распознаёт сам:
[Definition]
failregex = ^<HOST> -^<HOST> -—<HOST>это макрос fail2ban для IP, а^… -привязывает его к началу строки (за IP идёт-). Каждая строка лога = одно «нарушение», а приmaxretry = 1хватает первого.datepatternне нужен:[$time_local]— стандартный формат nginx-лога, и fail2ban распознаёт дату сам. Будь лог без метки (datepattern = {NONE}), fail2ban считал бы временем записи момент её прочтения: при рестарте или ротации лога старые строки выглядели бы свежими нарушениями, и давно ушедшие сканеры ловили бы бан заново. С реальной меткой такого не происходит — строки старше окнаfindtimeпросто игнорируются.
Проверить фильтр на живом логе можно ещё до включения jail:
sudo fail2ban-regex /var/log/nginx/honeypot.log \
/etc/fail2ban/filter.d/nginx-honeypot.confВ выводе должно быть Matched на все строки лога.
Jail: один запрос — и бан
Сам jail. Тут собрана вся политика бана:
[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.10maxretry = 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:
[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 (если ещё нет) и включаем автозапуск:
sudo apt update && sudo apt install fail2ban
sudo systemctl enable --now fail2banПосле правок конфигов перечитываем fail2ban и смотрим статус нашего jail:
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.
Полезные команды: статус и разбан
Посмотреть, кто сейчас в бане и сколько всего поймано:
# статистика и список IP в нашем jail
sudo fail2ban-client status nginx-honeypot
# забаненные адреса на уровне файрвола
sudo nft list table inet f2b-tableЕсли нужно именно снять бан (а не почистить лог — чистка баны не убирает, fail2ban держит состояние в своей БД):
# снять бан с конкретного 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 падает — откатывает изменения.
Безопасный путь — скачать, прочитать, запустить:
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:
curl -fsSL https://raw.githubusercontent.com/gistrec/nginx-scanner-trap/main/setup-honeypot.sh | sudo bashВ сумме это пара десятков строк конфига, которые заметно подчищают и логи, и — что для меня важнее — 404-алерты. Сканеры никуда не делись, но теперь первый же запрос к /.env отправляет бота в бан на сутки, а не превращается в сотню строк лога и алерт на ровном месте.