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

Немного цифр для масштаба. Первый коммит — 25 октября 2026, сейчас в репозитории 243 коммита и около 22 тысяч строк PHP, JS и CSS, плюс 5 тысяч строк серверных скриптов на Bash и Python. Зависимостей ноль: ни Composer, ни npm, ни CDN. Сайт делает ровно ноль запросов к чужим доменам — шрифты лежат рядом.

Почему без фреймворка и без базы данных

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

Поэтому весь контент — один JSON-файл. Сохранение выглядит так:

function data_save(array $data): void
{    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);    if (is_file(DATA_FILE) && file_get_contents(DATA_FILE) === $json) {        return;                                   // ничего не изменилось — копию не плодим    }    backup_create();                              // прежняя версия уходит в data/backups/, хранятся 20 последних    $tmp = DATA_FILE . '.' . new_id() . '.tmp';    file_put_contents($tmp, $json, LOCK_EX);    rename($tmp, DATA_FILE);                      // атомарная замена: читатель видит либо старый файл, либо новый
}

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

Цена решения:

  • Нет запросов. Поиск, фильтры, сортировки — всё руками, в памяти. На сотнях записей это незаметно, на сотнях тысяч было бы больно.

  • Конкурентная запись. Там, где пишут посетители (например, сводка нарушений CSP, о ней ниже), нужен flock() и ограничение на размер, иначе любой желающий раздует файл мусором.

  • Нет фреймворка — нет и его защиты. Экранирование, CSRF, сессии, ограничение попыток входа пришлось писать самому. Это же стало причиной написать тесты, которые атакуют сайт (о них дальше).

Без фреймворка я остался по той же причине: страниц около сорока, у каждой свой шаблон, а маршрутизация — это таблица разделов и перенаправлений старых адресов. Фреймворк закрыл бы задачи, которых у меня нет, и добавил бы обновления, которые надо отслеживать.

Строгая CSP: что пришлось переделать

Политика у сайта такая:

$csp = "default-src 'self'; "    . "script-src 'self' 'nonce-$nonce'; "    . "style-src 'self'; font-src 'self'; "    . "img-src 'self' data:; connect-src 'self'; object-src 'none'; "    . "base-uri 'self'; form-action 'self'; frame-ancestors 'none'";

Скрипты — только свои файлы и встроенные с одноразовым nonce (16 случайных байт на каждый ответ). Стили — только свои файлы, без 'unsafe-inline'. Звучит как одна строчка в конфиге, а на деле это перекроило вёрстку.

style="" запрещён. Это больнее всего. Когда у тебя графики, полоски доступности, тепловые карты, первая мысль — style="width: 37%". Под такой CSP браузер это молча проигнорирует. Что я сделал вместо этого:

  • графики и полосы рисуются в SVG, где ширина и высота — обычные атрибуты <rect width="…">, а не стиль;

  • то, что должно меняться на лету, JS выставляет через CSSOM (el.style.setProperty('--glitch-delay', '0s')) — это CSP не запрещает, запрет касается только атрибута в разметке;

  • всё остальное — классы.

Никаких onclick. Только addEventListener в своих файлах.

Данные для скриптов — не переменная в <script>, а <script type="application/json">, который не исполняется и читается через JSON.parse. Тут есть своя ловушка: если в данных встретится </script>, тег закроется раньше времени. На это есть отдельный тест.

Ранний скрипт. Выбранная тема и ночной режим должны включиться до отрисовки, иначе страница на долю секунды мигнёт не той темой. Это один из немногих встроенных скриптов, и он получает nonce.

И чтобы всё это не развалилось через месяц, когда я забуду и напишу style= в новом шаблоне, — тест:

test('CSP: nonce каждый раз новый, встроенных скриптов без nonce и style="" нет', function () use ($hdr) {    [, $h1, $html] = http('GET', '/');    [, $h2] = http('GET', '/');    preg_match("/'nonce-([^']+)'/", $hdr($h1, 'content-security-policy'), $m1);    preg_match("/'nonce-([^']+)'/", $hdr($h2, 'content-security-policy'), $m2);    check(($m1[1] ?? '') !== '' && $m1[1] !== ($m2[1] ?? ''), 'nonce повторяется');    preg_match_all('/<script\b[^>]*>/i', $html, $tags);    foreach ($tags[0] as $tag) {        check(str_contains($tag, 'src=') || str_contains($tag, 'nonce="' . $m1[1] . '"')            || str_contains($tag, 'application/json') || str_contains($tag, 'application/ld+json'), "скрипт без nonce: $tag");    }    check(!preg_match('/\sstyle\s*=/i', $html), 'есть атрибут style=""');
});

Недавно добавил ещё и отчёты о нарушениях (report-uri для старых браузеров, report-to для новых). Оказалось, что это полезно в первую очередь для своих ошибок: забытый nonce в новом шаблоне всплывает в сводке раньше, чем я его замечу глазами. Хранится только сводка — директива, источник без пути, страница, счётчик. Ни адресов, ни User-Agent. Отчёты от расширений браузера (chrome-extension://…) отбрасываются: это шум, а не проблема сайта. И есть потолок в 200 разных записей — иначе любой может завалить файл придуманными нарушениями.

Тесты, которые атакуют свой же сайт

Тестов два набора. Первый — php tests/run.php, 160 проверок, проходит примерно за 20 секунд и не требует ничего, кроме PHP. Фреймворка для тестов тоже нет: test(), check() и HTTP-клиент на fsockopen() — это около 50 строк. Сайт поднимается встроенным сервером PHP на временной копии данных, настоящие файлы не трогаются.

Главная идея — тестовые данные сами по себе атака. В каждом поле профиля, которое выводится на сайт, лежит что-нибудь неприятное:

const XSS = '<script>alert(1)</script><img src=x>';
$data['profile'] = array_merge($data['profile'], [    'name'      => 'Test ' . XSS,    'bio'       => "Bio \e]0;pwned\x07\e[2J" . XSS,   // escape-последовательности терминала — для curl-версии    'specialty' => '"><svg onload=alert(1)>',    // …
]);

А дальше проверки, что ничего из этого не вылезло. Несколько названий тестов, чтобы было понятно, о чём речь:

  • «Отражённый XSS: адрес и параметры не попадают в страницу как HTML»;

  • «Подменённый Host не попадает в ссылки страницы»;

  • «SSRF: /audit.json не ходит по адресу из запроса»;

  • «Перебор пароля: после 5 ошибок блокировка, даже для верного пароля»;

  • «CSRF: без токена или с чужим токеном форма не проходит»;

  • «JPEG: EXIF с GPS удаляется, картинка остаётся JPEG»;

  • «Закрытые файлы и каталоги не отдаются» — /.git/config, /data/site.json, /../ и прочее;

  • «Markdown: кавычка в адресе не выходит из атрибута».

Последний важен потому, что Markdown-парсер у меня тоже свой, и кавычка в адресе ссылки — классический способ выйти из атрибута href и дописать обработчик события.

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

Тесты встроены в выкладку: скрипт деплоя запускает их сам и ничего не выкладывает, если что-то упало. Обойти можно, но только явно, переменной окружения — чтобы обход был осознанным решением, а не привычкой.

Бюджет скорости и доступность в браузерных тестах

PHP-тесты не видят вёрстку и не исполняют JavaScript. Поэтому второй набор — tests/browser.py: настоящий Chromium через DevTools Protocol, без Selenium и Playwright, только python3-websocket. Он обходит 35 страниц в двух размерах — телефон 375×812 и компьютер 1280×800 — и на каждой проверяет:

  • нет ошибок в консоли, в том числе нарушений CSP;

  • нет горизонтальной прокрутки на телефоне — самая частая моя вёрсточная ошибка;

  • контраст и порядок заголовков в обеих темах;

  • в английской версии не осталось русского текста;

  • проход клавишей Tab: элемент в фокусе виден;

  • режим «уменьшить движение»: ничего не крутится;

  • бюджет скорости.

Бюджет — это словарь с порогами:

# Сейчас страницы ≈280 КБ и 14–19 запросов: запас оставлен под рост,
# а превышение значит, что что-то тяжёлое попало на страницу случайно.
BUDGET = {'kb': 400, 'html': 80, 'js': 150, 'req': 25, 'load': 1500}

Вес страницы со всем, что она подгрузила, HTML и скрипты отдельно, число запросов и время до load. Числа берутся из performance.getEntriesByType() прямо в странице. Если страница потяжелела — тест падает.

Зачем это личному сайту? Потому что без порога вес растёт незаметно: каждая новая фича добавляет «всего 20 килобайт». С порогом добавление тяжёлого скрипта становится решением, которое приходится принимать явно.

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

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

Очистка текста атакующих перед публикацией

Это часть, ради которой сайт вообще стал интересен: он показывает, что делают боты в SSH-ловушке. Но показывать сырые команды атакующих — плохая идея. В них ссылки на вредоносы, адреса командных серверов, иногда мат, иногда чужие данные. Публиковать это — значит раздавать ссылки на малварь со своего домена.

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

def show_cmd(raw):    c = re.sub(r'[^\x20-\x7e]', '', raw)                                   # только печатный ASCII    c = re.sub(r'\b[a-z][a-z0-9+.-]*://\S+', '<url>', c, flags=re.I)       # любые ссылки    c = re.sub(r'\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b', '<ip>', c)             # адреса    c = re.sub(r'"[^"]*"|\'[^\']*\'', '<str>', c)                          # строки в кавычках    c = re.sub(r'[A-Za-z0-9+/=]{32,}', '<blob>', c)                        # base64 и прочие длинные куски    ...

Дальше команда режется по ;, |, &&, и у каждой части проверяется программа. Незнакомая программа целиком становится <скрыто>. У знакомой остаются флаги, пути и права (chmod +x), а случайные имена файлов вида /tmp/x7f3k превращаются в <file>. Всё, что похоже на свободный текст, — в <arg>.

Получается, например, cd /tmp; wget <url>; chmod +x ./<file>; ./<file> — видно, что бот делал, и ничего, по чему можно куда-то перейти.

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

Цена: часть интересного теряется. Бывает, что самое любопытное в сессии — как раз незнакомая программа, а на сайте она <скрыто>. Я считаю, что это правильный размен.

Как отличить одного бота от многих, если адреса не публикуются

Это самый частый вопрос к первой статье: «вы пишете, что один бот пришёл с 18 адресов, — как вы это поняли, если IP не сохраняете?» Ответ в том, где именно адреса есть, а где их нет.

Адреса есть только в журнале ловушки. Ловушка без них бесполезна, поэтому пишет всё. Раз в 5 минут экспорт читает журнал за последние 7 дней и считает всё в памяти. В данные сайта уходят только итоговые числа.

«Бот» — это сценарий, а не адрес. У каждой сессии берутся первые 12 команд после той же очистки, что и для публикации, числа заменяются на N, и от этой последовательности считается короткий хеш — подпись поведения:

# разведка одной командой uname — один вид, с какими бы ключами и по какому пути её ни вызвали
if len(shown) == 1 and re.fullmatch(r'(\S*/)?uname( -[a-zA-Z]+)*', shown[0][1]):    sig = hashlib.sha1(b'uname').hexdigest()[:4]
else:    sig = hashlib.sha1('\n'.join(re.sub(r'\d+', 'N', c) for _, c, _ in shown[:12]).encode()).hexdigest()[:4]

Сессии с одинаковой подписью — одно семейство. Особый случай с uname появился после первых же настоящих данных: один бот вызывал её то как uname -a, то как /bin/./uname -s -v -n -r -m, и без этого правила распадался на десяток «видов».

«18 адресов» — это размер множества. Для каждого семейства в памяти собирается множество адресов, с которых пришли его сессии, а наружу уходит только его размер. Семейство, замеченное меньше чем с трёх адресов, не публикуется вовсе. С правилом для uname этот вид за две недели набрал 47 адресов и больше 30 тысяч сессий.

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

  • Отпечаток SSH-клиента (HASSH) — хеш от набора алгоритмов шифрования, который клиент предлагает при подключении. Строку версии бот пишет какую хочет, а набор алгоритмов выдаёт настоящую библиотеку. Так видно, например, что у клиента, который называет себя SSH-2.0-internetdata.io, тот же отпечаток, что у части клиентов SSH-2.0-Go: это одна и та же библиотека под разными вывесками.

  • Паспорт семейства — часы активности и любимые пароли. Если у двух видов один клиент и общие пароли, на сайте это помечено как «возможно, один оператор». Именно «возможно»: это повод присмотреться, а не вывод.

  • Родословная. Новое семейство связывается со старым, если их команды совпадают хотя бы на 60% (difflib.SequenceMatcher по спискам команд). Так нашлись два вида с общим скриптом закрепления и сходством 75%: один меняет пароль через passwd, другой — через chpasswd и вдобавок убивает чужие скрипты в /tmp, то есть выгоняет конкурентов.

  • Сети. Под каждым видом теперь видно, сколько адресов из хостингов и облаков, а сколько из обычных провайдеров: это отличает арендованный парк серверов от заражённых роутеров и камер. Подсказал это читатель англоязычной версии статьи, но предложил присылать адреса в сторонний сервис геолокации. Так я делать не стал: таблица «диапазон → сеть» скачивается на сервер раз в месяц, и поиск идёт по ней локально. Первый же источник, кстати, из России отдавал по 4 КБ в минуту — пришлось научить скрипт обрывать медленную загрузку и переходить к запасному. Первые результаты удивили: и тот разведчик с uname, и самый массовый вид, который дописывает свой SSH-ключ, примерно на три четверти и больше живут в сетях обычных провайдеров, а не в облаках. То есть это скорее заражённые устройства, чем арендованный парк. Оговорка: «хостинг или нет» определяется по названию сети, и часть облаков с непримечательными названиями попадает в «обычные провайдеры», так что это оценка, а не точный подсчёт.

Один адрес в нескольких ловушках. Кроме SSH-ловушки на сервере есть смоляная яма, приманки баз данных и ловушки на самом сайте. Чтобы увидеть, что один и тот же адрес обошёл несколько из них, каждая хранит не адрес, а HMAC от него с секретным ключом. Совпадение хешей говорит «это тот же адрес», а сам адрес из хеша не восстановить без ключа, который лежит только на сервере. За последние 30 дней ловушки видели 2986 адресов, 274 из них попали больше чем в одну. Самый частый путь — со сканирования сайта на фальшивую страницу входа WordPress, без паузы: сканер нашёл /wp-login.php и тут же пробует пароли. Второй — из SSH-ловушки в смоляную яму, тоже меньше чем за минуту: бот закончил с одним портом и сразу пробует соседний.

Ошибки

Второй брандмауэр

Я поставил смоляную яму и приманки баз данных, открыл порты в ufw — и два дня на них не пришло ни одного соединения. Локально всё работало. tcpdump показывал входящие SYN, счётчик правила ufw рос, ответа не было.

Оказалось, на сервере одновременно включён firewalld, и его цепочка в nftables идёт после цепочки ufw. Разрешение в одном не отменяет запрет в другом. Нашёл через nft list ruleset | grep -nE '^table|hook ' — там видно все таблицы и в каком порядке они цепляются к хукам.

Исправление — скрипт, который открывает порт во всех включённых брандмауэрах сразу, и правило для себя: не делать firewall-cmd --reload, потому что он может задеть сети контейнеров.

Ложная тревога сторожа

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

Ошибка в модели: «сервер не может выйти наружу» — это не то же самое, что «сайт недоступен». Теперь перед черновиком сторож смотрит журнал веб-сервера: если внешние запросы приходили в большинстве 10-минутных отрезков перерыва, сайт был доступен, и вместо разбора приходит пуш с пояснением.

if start <= ts < end and ip.is_global:    seen.add(int(ts - start) // 600)          # номер 10-минутного отрезка, где был внешний запрос
print(100 * len(seen) // max(1, -(-(end - start) // 600)))

Флешка, которая дважды положила сервер

Система у него стоит на флеш-накопителе. 27 октября и 8 ноября флешка зависла под записью, и сервер стоял от 7 до 16 часов.

Самое неприятное — аппаратный сторожевой таймер не помог. Ядро было живо и исправно его «кормило», просто все процессы, которым нужен был диск, висели. С точки зрения таймера всё в порядке.

Исправлений два:

  1. Ядро само перезагружается при зависшем диске. kernel.hung_task_timeout_secs = 300, hung_task_panic = 1, panic = 30: если процесс ждёт диск дольше 5 минут, ядро паникует и через 30 секунд перезагружается. Часы простоя превращаются в минуты. Риск понятен — законная операция дольше пяти минут без ответа тоже вызовет перезагрузку, но на этом сервере таких не бывает. След паники остаётся в pstore, и черновик разбора подхватывает его после перезагрузки.

  2. Убрать запись с флешки. Главным оставшимся источником записи была база системы блокировок. Её каталог перенесён на диск с данными и смонтирован на старое место через bind в /etc/fstab — пути в настройках не поменялись. Скрипт переноса проверяет каждый шаг и откатывает всё, если служба после переноса не поднялась. На него тоже есть тест.

Что бы я сделал иначе

  • Тесты на настоящих данных — с первого дня. Синтетика проверяет то, что я придумал. Живые журналы проверяют то, что придумали боты, а они изобретательнее.

  • CSP — до первой строчки вёрстки. Включить строгую политику на готовом сайте — это переписать половину шаблонов. Включить её в начале — просто так писать с самого начала.

  • Сторожу — проверять то, что видит пользователь, а не то, что видит сервер. Ложная тревога обошлась дешевле пропущенной, но доверие к полосе доступности она подточила.

Без базы данных и фреймворка за три недели ни разу не хотелось вернуться к ним. Хотелось другого — чтобы тесты появлялись раньше ошибок, а не после.

Как устроен сайт и из чего он сделан — на странице «Как устроен этот сайт», как он защищён — «Аудит», справочник всех видов ботов с паспортами, родословной и сетями — бестиарий, а разборы сбоев — на странице «Инциденты».