Две недели назад я закончил домашний проект — небольшую утилиту под Windows, — и решил, что ей нужна страница в интернете. Взял самый дешёвый VPS, так как много мощностей для моих задач не требуется, поднял nginx, выложил статическую страницу без единого внешнего скрипта. Счётчики вроде популярных метрик ставить не хотел принципиально: утилита не собирает никаких данных о пользователе, и было бы странно начинать “слежку” прямо со страницы о ней. Поэтому аналитику я сделал самую честную из возможных — разбор собственных логов nginx.

Через трое суток открыл сводку и увидел: 273 уникальных адреса, из них 165 открыли главную страницу. Для проекта, о котором не знает ровно никто — ни одной ссылки нигде, ни единого анонса — это выглядело как маленькое чудо. Чуда, разумеется, не было: живых посетителей за эти двое суток набралось около двадцати, а из четырёх скачиваний моего установщика ни одно не сделал человек!

Дальше больше: как я это выяснил, почему первый подход не сработал и что в итоге стоит на сервере?

Что вообще происходит на свежем домене?

Первым делом я посмотрел не на посещаемость, а на распределение кодов ответа — оно оказалось красноречивее любой сводки. Из 2 129 запросов успешными были только 613, а больше двух третей пришлось на ошибки. Причём ошибки не случайные, и это сразу видно по тому, за какими адресами ходили клиенты.

Код ответа

Сколько раз

403

934

200

613

404

487

304

35

400

20

405

19

401

5

Вот десятка самых популярных путей среди этих ошибок — классический перебор в чистом виде:

Количество

Путь

12

/.env

6

/api/.env

6

/.env.production

6

/.env.backup

6

/.git/config

5

/web/.env

5

/tmp/.env

5

/server/.env

5

/public/.env

5

/private/.env

Дальше по списку идут phpinfo.php во всех мыслимых и не мыслимых подпапках: /wp-admin/, /vendor/, /administrator/. Ботам нужен файл с паролями к базе, выгрузка git-репозитория или забытая админка — они обходят диапазоны адресов и пробуют наугад найти дыру в защите. На моём сайте нет ни PHP, ни базы, ни WordPress, только статические файлы, поэтому все попытки ушли в пустоту. Но проблема в том, что запросы-то сделаны, и в наивную статистику посещаемости они попадают наравне с живыми людьми.

Отдельно порадовали четыре адреса, давшие по: 276, 276, 269 и 269 запросов. Каждый из них за пару часов простучал под три сотни путей, и в сводке это выглядит как четыре заинтересованных очень настойчивых “посетителя”. Числа почти совпадают неспроста: это один и тот же сканер, запущенный с четырёх машин одного облачного провайдера. По отдельности каждый выглядит как посетитель с уникальным адресом, а вместе они дали больше половины всего «трафика» за сутки.

Попытка первая: фильтровать по User-Agent

Решение, которое приходит в голову первым: а что если отсеять всех, кто честно назвался роботом. В nginx - это делается картой, которая помечает запрос, плюс второй картой, дающей обратный флаг. Вторая нужна из-за особенности директивы if=: nginx пишет строку в лог, если переменная непустая и не равна нулю, поэтому «человечность» удобнее считать явно, а не полагаться на интуицию.

Карты для SET и GET флаг на запрос

map $http_user_agent $wc_bot {
    default 0;
    "~*(googlebot|yandexbot|bingbot|applebot|petalbot)"        1;
    "~*(gptbot|claudebot|ccbot|perplexitybot|bytespider)"      1;
    "~*(ahrefsbot|semrushbot|mj12bot|dotbot|blexbot)"          1;
    "~*(curl|wget|python-requests|go-http-client|okhttp)"      1;
    "~*(zgrab|masscan|censys|nuclei|sqlmap)"                   1;
    ""  1;
    "-" 1;
}
map $wc_bot $wc_human { 1 0; default 1; }
  

Дальше в блоке server пишем два лога вместо одного: полный — для разбора инцидентов и «человеческий» — для статистики.

Логи

access_log /var/log/nginx/site.access.log;
access_log /var/log/nginx/site.humans.log combined if=$wc_human;
 

Запустил, подождал, посмотрел — и разочаровался. По User-Agent отсеялось 267 запросов из 2 129, то есть около 12 %. При этом я своими глазами видел в логе адреса, которые ломились в /.env и представлялись как браузер Chrome под Windows. Вывод получился обидный, но полезный: честные роботы представляются честно, а нечестные — нет. Казалось бы очевидно? Но не все так прозрачно как кажется на первый взгляд. Поисковым системам, ИИ-краулерам и утилитам вроде curl репутация нужна, их можно заблокировать по имени, и они это знают. Сканеру уязвимостей на ресурсе репутация не нужна вовсе — он просто подставляет строку настоящего браузера, и любой фильтр по имени агента обходится без проблем.

Попытка вторая: смотреть не кто представился, а что сделал

Не сдаемся и копаем дальше! Я зашёл с другой стороны. Меня же интересует: не «как назвался клиент», а «был ли это человек с браузером»? А браузер отличается от скрипта поведением: получив HTML, он немедленно идёт за таблицей стилей, шрифтами и картинками, иначе ему нечего показать на экране. Сканеру содержимое страницы неинтересно, ему нужен только сам факт ответа, поэтому за оформлением он не возвращается. Отсюда косвенный признак: клиент считается живым, если забрал и страницу, и её оформление.

Проверяется это по логу одним проходом awk — сначала собираем адреса, которые хоть раз запросили стили или картинки, потом оставляем только их строки:

Сбор и проверка адресов

awk '
    { lines[NR] = $0; ip[NR] = $1 }
    $7 ==  "/styles.css" || $7 ~ /^\/assets\// { browser[$1] = 1 }
    END {
        for (i = 1; i <= NR; i++)
            if (ip[i] in browser) print lines[i]
    }
' access.log > real.log
 

Результат отрезвил окончательно. Двадцать один живой клиент против двухсот семидесяти трёх «уникальных посетителей» — это ровно 8 % от того, что показывал наивный подсчёт, и в двенадцать раз меньше, чем я увидел в первой сводке. Причём фильтр по имени агента, на который я неспешно потратил вечер, почти ничего не изменил: 168 против 165 — разница в пределах шума. Всю работу сделал один поведенческий признак, который пишется в пять строк.

Как считать

Сколько «посетителей»

Все уникальные адреса

273

Открыли главную и получили 200

165

Отсеяны только представившиеся роботы

168

Забрали страницу вместе со стилями

21

И это ещё не конец истории. Я прогнал оставшуюся двадцатку через определение типа сети — хотелось понять, откуда адрес: из домашнего интернета или из дата-центра. Выяснилось, что среди этой двадцатки четверо тоже роботы: два обращения от краулера одной ИИ-компании, по одному — от поискового робота и краулера другой ИИ-компании. Они грузят стили и картинки совершенно честно, потому что рендерят страницу целиком, и поведенческий признак их не ловит. Больше половины оставшихся адресов принадлежали хостинг-провайдерам, то есть тоже не людям с домашним интернетом, а чему-то запущенному на арендованной машине. Реальных живых людей за двое суток набралось около полудесятка, включая меня самого.

Самое неприятное открытие: скачивания

Дальше я посмотрел на то, ради чего сайт вообще существует, — на кнопку «Скачать». Установщик весит около 67 МБ и отдаётся с сервера напрямую, так что каждое скачивание видно в логе с точным числом переданных байт. За те же двое суток к файлу было 11 обращений, из которых 4 закончились полной отдачей. Четыре скачивания за два дня для проекта без единого анонса — я бы порадовался, если бы не посмотрел, кто их сделал.

Кто скачал файл целиком

Сколько раз

Краулер одной ИИ-компании

3

Я сам, проверяя, что кнопка работает

1

Пользовательских сторонних скачиваний — ноль. Остальные семь обращений оборвались на первых сотнях килобайт: кто-то из облака дёрнул начало файла и отвалился, поисковый робот забрал 278 КБ, видимо, определяя тип содержимого. Отдельно стоит посчитать расход: три полных выкачивания — это 200 МБ трафика, потраченных на то, чтобы бинарник уехал в чей-то датасет. На дешёвом VPS с лимитом это уже заметно, а при раздаче с платного объектного хранилища превратилось бы в счёт.

Отсюда вывод, который я бы вынес отдельно для всех, кто раздаёт файлы: счётчик скачиваний с релизной страницы или из хранилища — не метрика. Он считает всех, кто подключился, и не отличает человека от робота. Если хочется знать реальное число, считать придётся самому: завершённую отдачу файла по объёму переданных байт и только с адреса, который до этого вёл себя как браузер. При таком подходе конечно не будут учитываться живые скачивания, не доведенные до конца - передумал, но думаю, что это не самый худший компромисс.

Что я сделал с этим дальше

Понимание пониманием, но 70 % мусорных запросов никуда не делись: они жгут процессор, забивают логи и мешают смотреть на реальную картину. Городить капчу или ставить перед сайтом чужой антибот-сервис я не хотел — это посредник между читателем и статикой, да и странно бороться со слежкой, добавляя ещё одного наблюдателя. Поэтому обошёлся штатными средствами: четыре меры, от самой мягкой к самой жёсткой, и ни одна из них не мешает поисковой индексации.

Первое — обрывать заведомо чужие запросы

У меня нет и не будет ни PHP, ни WordPress, ни git на сервере, поэтому любой такой запрос заведомо чужой. Отдавать на него честную страницу 404 незачем: это лишняя работа, лишний ответ и лишняя строка в общем логе. Нестандартный код 444 специфичен для nginx и означает «закрыть соединение, ничего не отвечая» — сканер не получает никакой обратной связи, а я получаю отдельный лог с адресами тех, кто туда лез. Приятный бонус для аналитики.

Сбор информации по коду 444

location ~* (^/\.env|^/\.git|\.php$|^/wp-|^/vendor/|^/phpmyadmin) {
    access_log /var/log/nginx/site.blocked.log;
    return 444;
}
 

Второе — ограничение частоты

Человек, открывая страницу, делает десяток запросов разом (HTML, стили, иконки, картинки) и затихает, а перебор идёт ровным потоком в сотни запросов. Эта разница ловится штатным модулем: всплеск в 30 запросов покрывает загрузку страницы с запасом, а равномерный поток упирается в лимит и получает 429. Я проверил на себе — пятнадцать запросов подряд к разным файлам прошли без единого отказа.

Ограничение частоты запросов

limit_req_zone $binary_remote_addr zone=main:10m rate=10r/s;
limit_req_status 429;
# в server{}
limit_req zone=main burst=30 nodelay;
 

Третье — банить упорных

Раз мусорные запросы теперь пишутся в отдельный лог, для fail2ban не нужен хитрый шаблон: подозрительна любая строка в этом файле, надо лишь вытащить из неё адрес. Три попытки за час — и клиент уходит в бан на сутки. Ложные срабатывания тут практически исключены: живой человек в /.env не заходит никогда, а поисковые роботы такие пути не запрашивают, так что индексации это не вредит.

Фильтр и правило для fail2ban

Фильтр /etc/fail2ban/filter.d/scanners.conf:
ini
[Definition]
failregex = ^ -.*"(GET|POST|HEAD|PUT|DELETE|OPTIONS|PATCH).*"
ignoreregex =
datepattern = ^[^\[]*\[({DATE})
Правило /etc/fail2ban/jail.d/site.conf:
ini
[scanners]
enabled  = true
port     = http,https
filter   = scanners
logpath  = /var/log/nginx/site.blocked.log
maxretry = 3
findtime = 1h
bantime  = 24h
 

Четвёртое — попросить роботов не трогать тяжёлый файл

Те, кто уважает robots.txt, а поисковые и ИИ-краулеры его уважают, послушаются. Заодно я закрыл целиком несколько сервисов SEO-разведки: они выкачивают сайт ради чужой аналитики и не приносят мне ничего, кроме трафика.

настройка в robots.txt

User-agent: *
Disallow: /download

Что в итоге

Через сутки после всех мер логи стали заметно чище, но главное даже не в этом: я перестал обманывать себя цифрами. Сейчас у меня три уровня статистики вместо одной сводки: живые посетители, расширенный отчёт без опознанных роботов и полный трафик со всеми сканерами. Решения принимаются по первому, второй нужен для сравнения, третий — чтобы разбирать инциденты.

Пять вещей, которые я вынес из этой истории

1. Уникальные адреса — это не посетители. На свежем сайте без единой входящей ссылки разница оказалась двенадцатикратной, и весь «трафик» состоял из роботов.

2. Фильтр по User-Agent отсекает только вежливых. Он поймал 12 % запросов и пропустил всех, кто маскируется под браузер, то есть именно тех, из-за кого цифры и раздуваются.

3. Поведение надёжнее самоназвания. Один признак: «забрал страницу вместе со стилями» дал больше, чем список из полусотни известных ботов, и его не нужно дописывать при появлении новых сканеров.

4. Счётчик скачиваний врёт по умолчанию. Пока вы не считаете завершённую отдачу файла с адреса живого посетителя, вы считаете роботов и оборванные соединения.

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

Про ИИ-краулеров добавлю отдельно, чтобы это не прозвучало обвинением: они ходили по robots.txt, представлялись честно и ничего не ломали, претензий к ним у меня нет. Просто если вы раздаёте с сайта тяжёлые файлы, будьте готовы, что скачают их не только люди, и решите заранее, устраивает вас это или нет. Меня в итоге устроило чтение страниц, а бинарник я от них закрыл.

Спасибо, если прочитали до конца!

Надеюсь, статья окажется для вас полезной.