Как я защитил свой VPN от DPI: graylist + nginx stream + немного паранойи

от автора

«Если ваш VPN не детектирует зонды — он уже скомпрометирован»

Стандартная схема Reality+Xray работает по принципу «не пойман — не вор». Зонд подключается без правильного SNI → получает редирект на легитимный сайт → уходит. Но что если зонд знает ваш точный SNI? Что если он стучится не один раз, а методично, каждые 5 минут, из разных подсетей?

Я столкнулся с этим в Беларуси: за трое суток работы скрипта/детектора — ни одного «глупого» зонда с пустым SNI, только аккуратные коннекты с правильным google.com и аномальным поведением. Пришлось добавить два слоя защиты поверх стандартной схемы:

  1. Детектор — анализирует nginx-лог и pcap-файлы, считает «очки подозрительности» по поведенческим признакам

  2. Серый список —автоматически перенаправляет выявленные зонды на 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_prereadon — 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:20193.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.shexec /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

Частые коннекты с одного IP

burst (≥10/5с)

+3

Всплеск за короткое окно

correlated

+5

Главный признак: зонд появился через 1-3с после легитимного пакета

reputation

+2..+5

Повторные визиты того же IP

tcp window аномальный

+3

Из pcap: 0, 512, 1024 — типично для сканеров

MSS отсутствует/нестандартный

+2

Из pcap

TTL нестандартный

+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% автоматических зондов.

Три главных вывода:

  1. Не баньте — обманывайте. Зонд, получивший нормальный сайт, не понимает,что его поймали.

  2. Поведение важнее сигнатур. Корреляция повремени ловит даже «умных» зондов справильным SNI.

  3. Автоматизация — ваш друг. Серый список + graceful reloadработают без вашего участия.

Может возникнуть вопрос — а есть ли в этом смысл, ведь xray сам может отправлять на fallback трафик который не прошел reality handshake? Смысл есть — это разные слои защиты. Xray fallback отвечает на вопрос «что показать если уже достучались», детектор отвечает на вопрос «кого вообще не пускать дальше nginx». Вместе это defence‑in‑depth: зонд не только видит легитимный контент, но и активно блокируется на сетевом уровне после обнаружения паттерна.

Исходный код

P.S. И да, я конечно пользовался AI для подготовки материала

ссылка на оригинал статьи https://habr.com/ru/articles/1022494/