segflt2 часа назад
Как я защитил свой VPN от DPI: graylist + nginx stream + немного паранойи
Уровень сложностиСреднийВремя на прочтение5 минОхват и читатели5.5K*nix*МнениеИз песочницы«Если ваш 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. Но это ошибка:
ДействиеЧто видит зондЧто думает оператор / системаDROP / REJECTТаймаут или 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 logSNI, backend, bytes, durationВ реальном времениtcpdump pcapTCP-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).
ПризнакБаллыКомментарийbackend=fallback+2Не прошёл SNI-роутингSNI=empty+3Зонд без домена (браузеры так не делают)SNI≠ожидаемый+1Слабый сигнал, но в плюсtiny bytes (<300)+2Только хендшейк, нет данных приложенияshort session (<2с)+3Соединение оборвано сразуrate (≥4/60с)+2Частые коннекты с одного IPburst (≥10/5с)+3Всплеск за короткое окноcorrelated+5Главный признак: зонд появился через 1-3с после легитимного пакетаreputation+2..+5Повторные визиты того же IPtcp window аномальный+3Из pcap: 0, 512, 1024 — типично для сканеровMSS отсутствует/нестандартный+2Из pcapTTL нестандартный+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 для подготовки материалаТеги:• reality
• dpiХабы:• *nix
Получайте больше инсайтов о систематизации бизнеса
Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья