«Если ваш VPN не детектирует зонды — он уже скомпрометирован»
Стандартная схема Reality+Xray работает по принципу «не пойман — не вор». Зонд подключается без правильного SNI → получает редирект на легитимный сайт → уходит. Но что если зонд знает ваш точный SNI? Что если он стучится не один раз, а методично, каждые 5 минут, из разных подсетей?
Я столкнулся с этим в Беларуси: за трое суток работы скрипта/детектора — ни одного «глупого» зонда с пустым SNI, только аккуратные коннекты с правильным google.com и аномальным поведением. Пришлось добавить два слоя защиты поверх стандартной схемы:
Детектор — анализирует nginx-лог и pcap-файлы, считает «очки подозрительности» по поведенческим признакам
Серый список — автоматически перенаправляет выявленные зонды на fallback-сайт, не давая им добраться до VPN
Ключевая идея: зонд не должен понимать, что его поймали. Он должен получить то, что ожидает увидеть, и уйти с мыслью «тут ничего интересного». То есть обнаруженный зонд помещается в серый список и при всех повторных попытках сканировать 443 порт, даже с верным SNI, будет выполнен принудительный редирект на fallback.
Почему просто забанить через iptables не вариант?
Казалось бы: поймали подозрительный IP → iptables -A INPUT -s 1.2.3.4 -j DROP. Но это ошибка:
Действие | Что видит зонд | Что думает оператор / система |
|---|---|---|
| Таймаут или RST | «Тут что-то скрывают → помечаем как устойчивый к зондам → добавляем в блок-лист» |
Перенаправление на fallback | Обычный сайт с валидным сертификатом, нормальный TLS-хендшейк | «Просто сайт, идём дальше» |
Вывод: лучший способ скрыть, что вас детектируют — дать зонду ожидаемый ответ.
Архитектура

Контейнеры (docker-compose)
services: vless: # xray-core, слушает 1080 fallback: # nginx, отдаёт легитимный сайт на :443 nginx_stream: # фронтенд, слушает :443, SNI-роутинг + graylist nginx-proxy: # HTTP :80, ACME challenge для letsencrypt dockergen: # генерирует конфиг nginx-proxy по меткам контейнеров letsencrypt: # acme-companion, автовыпуск сертификатов watchtower: # автообновление образов
nginx-stream.conf
user nginx; worker_processes auto; events { worker_connections 1024; } stream { resolver 127.0.0.11 valid=10s ipv6=off; # === Серый список — читается из файла на хосте === geo $suspicious { default 0; include /etc/nginx/graylist.conf; # формат: "1.2.3.4 1;" } # === SNI-роутинг === map $ssl_preread_server_name $backend { google.com vless:1080; default fallback:443; } # === Финальное решение: зонды из серого списка — только на fallback === map $suspicious $final_backend { 0 $backend; 1 fallback:443; } log_format proxy '$remote_addr -> $backend [$time_local] $status ' 'bytes=$bytes_sent/$bytes_received conn=$connection ' 'sni=$ssl_preread_server_name duration=$session_time ' 'proto=$protocol'; access_log /var/log/nginx/stream-access.log proxy; server { listen 443; ssl_preread on; # читаем SNI, не терминируя TLS proxy_pass $final_backend; # ← ключевая директива proxy_connect_timeout 5s; proxy_timeout 3600s; proxy_socket_keepalive on; } }
Ключевые моменты:
ssl_preread on— nginx читает SNI из ClientHello, не расшифровывая трафикgeo $suspicious— подгружает серый список из файла на хосте (не в контейнере)$final_backend— финальное решение с учётом и SNI, и серого спискаduration=$session_time— длительность сессии, критична для детектора
Серый список: как это работает
Файл /etc/nginx/graylist.conf — обычный список IP в формате nginx geo:
# Graylist IPs — формат: "IP 1;" 80.94.95.221 1; # added 2026-04-02T14:59:20 193.232.56.12 1; # added 2026-04-03T09:15:44
После добавления IP детектор выполняет:
docker exec nginx_stream nginx -s reload
Это graceful reload: nginx перечитывает конфиг за ~50 мс, не разрывая активные соединения. Зонд с этого IP при следующем подключении получит fallback-сайт. С его точки зрения — обычный HTTPS-сервер, ничего интересного.
Детектор: поведенческий анализ вместо сигнатур
Источник | Что даёт | Частота обновления |
|---|---|---|
nginx stream log | SNI, backend, bytes, duration | В реальном времени |
tcpdump pcap | TCP-fingerprint: window, MSS, TTL | В реальном времени |
Захват пакетов: tcpdump-tls.sh
#!/usr/bin/env bash # /usr/local/sbin/tcpdump-tls.sh exec /usr/bin/tcpdump \ -i eth0 \ -Z root \ -w /var/log/tcpdump/tls-%s.pcap \ -G 300 \ # ротация каждые 5 минут -n \ 'tcp port 443 and tcp[tcpflags] & tcp-syn != 0' # только SYN-пакеты
Scoring: система очков подозрительности
Детектор не принимает бинарных решений. Каждый признак добавляет очки, алерт срабатывает при достижении порога (score_threshold: 5).
Признак | Баллы | Комментарий |
|---|---|---|
| +2 | Не прошёл SNI-роутинг |
| +3 | Зонд без домена (браузеры так не делают) |
| +1 | Слабый сигнал, но в плюс |
| +2 | Только хендшейк, нет данных приложения |
| +3 | Соединение оборвано сразу |
| +2 | Частые коннекты с одного IP |
| +3 | Всплеск за короткое окно |
| +5 | Главный признак: зонд появился через 1-3с после легитимного пакета |
| +2..+5 | Повторные визиты того же IP |
| +3 | Из pcap: 0, 512, 1024 — типично для сканеров |
| +2 | Из pcap |
| +1 | Из pcap: 255 или <50 |
Корреляция по времени — самый весомый признак
DPI реагирует на трафик: зонд появляется через 1-3 секунды после легитимного пакета клиента. Случайный сканер такой корреляции не даст.
14:59:15 37.212.28.144 → vless:1080 sni=google.com ← ваш клиент 14:59:16 80.94.95.221 → fallback sni=- ← зонд (+5 за correlation)
Как повторить
Требования
✅ Работающий VLESS+Reality setup (xray-core)
✅ Регистрация субдомена на ваш IP VPS
✅ Docker + docker-compose на хосте
✅ root-доступ к хосту (для iptables/tcpdump)
✅ Python 3.10+ с пакетами
pyyaml,dpkt
$cd /home/$USER $git clone https://github.com/segflt-wq/vless.git $cd /home/$USER/vless
Правим в docker-compose.yml переменные окружения под ваш субдомен и токен, в fallback.conf правим субдомен, в server.json генерим uuid и privateKey, меняем shortIds на свои, что нибудь еще по желанию. Настройки клиента подгоняем под сервер. Правим файл telegram под своего бота, или не правим, скрипт что нибудь напишет в логи. Детектор настроен на белорусский сегмент сети, адаптация под ru в папке ./vless/dpi-alert/ru. После всех правок делаем $./setup.sh — создаст каталоги, скопирует файлы конфигурации.
$docker compose up -d
Смотрим логи контов, материмся, донастраиваем letsencrypt и что нибудь еще…
Заключение
Защита от DPI — это не «поставил и забыл», а постоянная игра в кошки-мышки. Но даже простые эвристики (SNI + время + объём трафика) позволяют отсеивать 90% автоматических зондов.
Три главных вывода:
Не баньте — обманывайте. Зонд, получивший нормальный сайт, не понимает, что его поймали.
Поведение важнее сигнатур. Корреляция по времени ловит даже «умных» зондов с правильным SNI.
Автоматизация — ваш друг. Серый список + graceful reload работают без вашего участия.
Может возникнуть вопрос — а есть ли в этом смысл, ведь xray сам может отправлять на fallback трафик который не прошел reality handshake? Смысл есть — это разные слои защиты. Xray fallback отвечает на вопрос «что показать если уже достучались», детектор отвечает на вопрос «кого вообще не пускать дальше nginx». Вместе это defence‑in‑depth: зонд не только видит легитимный контент, но и активно блокируется на сетевом уровне после обнаружения паттерна.
P.S. И да, я конечно пользовался AI для подготовки материала
