Свой прокси против DPI: Xray, VLESS и REALITY

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

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

Что такое DPI и на что он смотрит, разберём ниже по порядку. А сама статья — о связке, сделанной против этого: Xray с протоколом VLESS, потоком XTLS Vision и маскировкой REALITY. Со стороны такое соединение выглядит как обычный HTTPS к большому зарубежному сайту, и даже если проверяющий подключится к серверу сам, он увидит этот сайт с его настоящим сертификатом.

В статье: что видит и как блокирует DPI, почему обычные VPN и прокси он узнаёт, как устроены VLESS, Vision и REALITY по байтам, как поднять сервер с IPv4 и IPv6, как добавлять и отзывать пользователей и как настроить клиент v2rayN на Windows — в режиме системного прокси и в режиме TUN, с двумя вариантами маршрутизации.

Статья опирается на две части серии «Сети для самых маленьких»: двадцатую, «VPN и прокси», где разобрано, что из этого видит провайдер, и двадцать третью, про TLS — кто её не читал, загляните хотя бы в раздел про рукопожатие TLS 1.3.

Адреса в статье документационные: сервер 203.0.113.10 и 2001:db8::10, сайт-маска www.example.com. Ключи — заглушки в угловых скобках.


Что видит DPI

DPI, Deep Packet Inspection, — оборудование провайдера, которое смотрит не только в заголовки IP и TCP (кто, кому, на какой порт), но и внутрь данных: какой протокол, к какому сайту, похоже ли это на что-то запрещённое. Стоит оно в разрыве канала или на зеркале трафика и решает, пропустить соединение, замедлить или оборвать (обычно поддельным TCP RST, пакетом «сбросить соединение», который DPI отправляет от имени одной из сторон).

На что он может опереться:

  • Адреса. Список запрещённых IP и подсетей. Самое грубое и самое надёжное: если адрес вашего сервера попал в список, никакая маскировка не поможет.
  • DNS. Обычный запрос DNS (двенадцатая часть) идёт по UDP на порт 53 открытым текстом: имя сайта видно, а ответ можно подменить.
  • SNI. В первом сообщении TLS, ClientHello, клиент открытым текстом пишет имя сайта в расширении server_name (Server Name Indication) — серверу нужно знать, какой сертификат показать. DPI читает его и режет по имени.
  • Отпечаток клиента. ClientHello у Chrome, Firefox, curl и самодельной программы разный: порядок шифров, набор и порядок расширений, поддерживаемые группы. Из этих полей считают хеш-отпечаток (самые известные методы — JA3 и JA4), и необычный отпечаток сам по себе подозрителен.
  • Сигнатуры протоколов. У каждого VPN-протокола узнаваемое рукопожатие: фиксированные байты, фиксированные размеры первых пакетов.
  • Статистика. Размеры и интервалы пакетов, объём и длительность соединений. Например, TLS внутри TLS даёт характерный рисунок размеров — об этом ниже.
  • Активное зондирование. Если сервер подозрителен, DPI подключается к нему сам и смотрит, что ответят: настоящий веб-сервер, прокси или тишина. Так узнают серверы, которые на «неправильный» запрос ведут себя не как обычный сайт.

Напомню, что видно в TLS 1.3. Открыты только ClientHello и ServerHello: версия, случайные числа, шифры, расширения, SNI, ALPN (список протоколов уровня приложения, например h2 для HTTP/2 и http/1.1) и key_share — открытые ключи обмена Диффи — Хеллмана. Всё, что после ServerHello, включая сертификат сервера, уже зашифровано. Это важно: в TLS 1.3 наблюдатель не видит сертификат, и REALITY этим пользуется.

Почему обычные VPN не подходят

  • IPsec с IKEv2 (девятнадцатая часть): IKE ходит по UDP на порты 500 и 4500, первое сообщение IKE_SA_INIT начинается с восьмибайтового SPI инициатора, нулевого SPI ответчика и номера версии 0x20 — узнаётся по первому пакету. Данные идут в ESP, отдельном IP-протоколе 50 или в UDP 4500.
  • WireGuard: UDP, первое сообщение — тип 1 и ровно 148 байт, ответ — тип 2 и 92 байта. Шифрование сильное, но форма пакетов постоянна.
  • OpenVPN: свой протокол поверх UDP или TCP, первый пакет начинается с кода операции P_CONTROL_HARD_RESET_CLIENT. Даже на TCP 443 перед ним стоит двухбайтовая длина — на TLS не похоже.
  • SOCKS5 — протокол прокси, RFC 1928: клиент начинает с байтов 05 01 00 (версия 5, один метод аутентификации, «без пароля»), дальше адрес назначения открытым текстом. Шифрования нет вовсе.
  • Shadowsocks — прокси, где поток зашифрован с первого байта и выглядит как случайный шум. Против сигнатур это работает, но «полностью случайный поток без единого узнаваемого байта» — сам по себе признак: обычный трафик так не выглядит. А сервер, который на мусорный запрос молчит, хорошо виден при активном зондировании.

Вывод напрашивается: прятаться надо не в шуме, а в самом обычном трафике интернета — в HTTPS к большому сайту. Это и делает связка из этой статьи.

Из чего она собрана

Xray — программа-прокси с открытым кодом, ответвление от проекта V2Ray. Это одно ядро, которое работает и как сервер, и как клиент: принимает соединения по разным протоколам (входы, inbounds), решает по правилам маршрутизации, куда их отправить, и отправляет через выходы (outbounds) — напрямую в интернет, в другой прокси или в никуда. Протоколы и маскировки, о которых ниже, — это модули Xray.

VLESS

VLESS — протокол прокси без собственного шифрования: он только говорит серверу, кто вы и куда соединиться, а защищает его транспорт под ним (у нас REALITY). Запрос клиента по байтам:

Поле Байт Что
Версия 1 0
UUID 16 идентификатор пользователя — это и есть «пароль»
Длина дополнений 1 M
Дополнения M Protobuf-структура, сейчас в ней только flow, например xtls-rprx-vision
Команда 1 1 — TCP, 2 — UDP, 3 — мультиплексирование (Mux)
Порт 2 порт назначения
Тип адреса 1 1 — IPv4, 2 — доменное имя, 3 — IPv6
Адрес 4, 1+N или 16 адрес назначения

Сразу за заголовком идут данные. Ответ сервера ещё проще: байт версии, байт длины дополнений, дополнения — и дальше данные от назначения. Protobuf здесь — двоичный формат сериализации Google: поля с номерами и длинами, компактнее JSON.

UUID — 128-битное случайное число в записи вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. Сервер хранит список UUID, у каждого пользователя свой; неизвестный UUID — соединение закрывается. Отсюда простое управление доступом: добавить пользователя — добавить UUID, отозвать — убрать его.

XTLS Vision и проблема TLS внутри TLS

Почти всё, что вы откроете через прокси, само будет HTTPS. Получается TLS внутри TLS: внутреннее рукопожатие (ClientHello браузера к настоящему сайту, ServerHello, сертификат) едет внутри внешнего зашифрованного потока. Содержимого DPI не видит, но видит размеры: первые записи внешнего соединения повторяют размеры внутреннего рукопожатия — ClientHello около 500 байт, потом несколько килобайт сертификатов, — и этот рисунок не похож на обычный HTTPS, где после рукопожатия сразу идут запросы. Кроме того, данные шифруются дважды, что стоит процессора.

XTLS Vision (значение flow: xtls-rprx-vision) делает две вещи. Первые пакеты соединения, пока идёт внутреннее рукопожатие, он добивает до случайных размеров (padding), размывая рисунок. А когда внутреннее рукопожатие TLS 1.3 закончено, Vision перестаёт перешифровывать: внутренние записи TLS, и так зашифрованные ключами браузера и сайта, идут во внешнем соединении как есть. Наблюдатель видит один поток записей TLS, как у обычного сайта, а сервер не тратит время на второй слой шифрования.

REALITY

REALITY — маскировка транспорта, которая заменяет обычный TLS с собственным сертификатом. С обычным TLS прокси пришлось бы завести домен, выпустить сертификат и держать на сервере сайт-обманку; любой, кто подключится к серверу, увидит этот малоизвестный домен. REALITY вместо этого выдаёт себя за чужой большой сайт — сайт-маску — и делает это так, что проверка подключением ничего не даёт.

Сначала два криптографических кирпичика:

  • X25519 — обмен ключами Диффи — Хеллмана на эллиптической кривой Curve25519. У каждой стороны пара: закрытый ключ (32 случайных байта) и открытый (результат умножения точки кривой на закрытый ключ). Каждая сторона берёт свой закрытый ключ и чужой открытый и получает одно и то же общее число, а наблюдатель, видевший только открытые ключи, его не получит. Этим же обменом TLS 1.3 договаривается о ключах в key_share.
  • HKDF и AES-GCM. HKDF — функция, которая из общего секрета выводит ключи нужной длины. AES-GCM — шифр с проверкой целостности: он шифрует данные и добавляет метку, по которой получатель проверяет, что ничего не подменено; можно дополнительно «привязать» к метке открытые данные (AAD), не шифруя их.

У сервера REALITY постоянная пара X25519: закрытый ключ лежит в его конфиге, открытый раздаётся клиентам. Ещё у сервера есть список shortId — коротких (до 8 байт) идентификаторов, и имя сайта-маски. Как идёт подключение:

  1. Клиент собирает ClientHello, неотличимый от браузерного. Для этого Xray использует uTLS — библиотеку, которая воспроизводит ClientHello конкретного браузера байт в байт (fingerprint: chrome), так что отпечаток JA3/JA4 совпадает с настоящим Chrome. В SNI — имя сайта-маски, www.example.com; в ALPN — h2 и http/1.1, как у браузера; в key_share — обычный одноразовый открытый ключ X25519.
  2. Клиент прячет метку в session_id. Это поле ClientHello длиной 32 байта в TLS 1.3 оставлено для совместимости и заполняется случайными байтами — наблюдатель ничего другого там и не ждёт. Клиент считает общий секрет X25519 между своим одноразовым закрытым ключом и постоянным открытым ключом сервера, выводит из него HKDF-ом ключ и шифрует AES-GCM-ом несколько байт: версию Xray, текущее время и shortId; в качестве AAD берёт весь ClientHello. Результат кладёт в session_id. Снаружи это всё те же 32 случайных байта.
  3. Сервер проверяет. Он берёт из key_share одноразовый открытый ключ клиента, свой постоянный закрытый, получает тот же общий секрет и пробует расшифровать session_id. Если расшифровка удалась, shortId есть в списке, время в допустимых пределах, а SNI — из разрешённых имён, клиент свой.
  4. Чужой — получает настоящий сайт. Если проверка не прошла (это сканер, проверка DPI, браузер, случайно зашедший на адрес), сервер не отвечает сам: он открывает соединение к www.example.com:443 и дальше просто пересылает байты в обе стороны. Проверяющий проходит полное рукопожатие с настоящим сайтом, видит его настоящий сертификат от публичного удостоверяющего центра и настоящие страницы. Отличить такой сервер от обратного прокси перед сайтом подключением невозможно.
  5. Свой — получает свой TLS. Если проверка прошла, сервер сам завершает рукопожатие TLS 1.3 и отправляет «временный» сертификат, подпись которого посчитана от общего секрета (HMAC — код аутентификации на основе хеша и ключа). Клиент проверяет эту подпись: только сервер с правильным закрытым ключом мог её получить, поэтому подмену посередине клиент заметит. А наблюдатель этого сертификата не видит вовсе: в TLS 1.3 сертификат идёт уже зашифрованным.
  6. Дальше — внутри этого TLS идёт запрос VLESS с UUID, а за ним данные с Vision.

Отсюда требования к сайту-маске. Он должен поддерживать TLS 1.3 (иначе сертификат пошёл бы открытым и выдал подмену) и X25519 в key_share. Желательно HTTP/2, потому что клиент, как браузер, предлагает h2. И он должен открываться из вашей сети: если маска заблокирована, ваш прокси заблокирован вместе с ней.

Два уровня защиты, и это важно для управления доступом. Ключи REALITY и shortId общие для всех пользователей сервера — они пропускают через маскировку. А кто именно пользуется прокси, решает UUID в VLESS: тот, кто знает ключи, но не знает действующий UUID, дальше рукопожатия не пройдёт.

Выбор сайта-маски

Критерии:

  • TLS 1.3, X25519, HTTP/2;
  • ответ 200 на главной без редиректа на другое имя;
  • быстрый ответ с вашего сервера: рукопожатие сервера с маской входит в каждое «чужое» подключение, и долгая задержка заметна;
  • открывается из вашей сети напрямую;
  • не самый популярный выбор: www.microsoft.com и www.apple.com для REALITY берут чаще всего, и именно их сочетание с адресами хостинг-провайдеров DPI логично проверять первым;
  • лучше всего — сайт из того же региона, что и сервер. Совсем идеально — сайт, который физически хостится в той же сети, что ваш сервер: тогда не будет даже несоответствия «SNI большого сайта, а IP — у VPS-хостинга». Такой найти трудно, и это остаётся главной слабостью схемы.

Проверить кандидата можно прямо с сервера, Xray для этого умеет пинговать TLS:

xray tls ping www.example.com
curl -s -o /dev/null --http2 -w "%{http_version} %{http_code} tls=%{time_appconnect}s redirect=%{redirect_url}\n" https://www.example.com/

xray tls ping делает рукопожатие без SNI и с SNI и показывает версию TLS, наличие обмена ключами (сейчас крупные сайты отвечают гибридом X25519MLKEM768 — X25519 плюс постквантовый ML-KEM, это подходит), цепочку сертификатов и имена, на которые выписан сертификат. Отбрасывайте кандидатов с TLS 1.2, с редиректом на другой домен и с сертификатом не на то имя. Последнюю проверку сделайте у себя дома, без прокси: сайт должен открываться.

Сервер

Нужна виртуальная машина за пределами зоны блокировок с публичными IPv4 и IPv6. Ресурсов хватает самых маленьких: 1 vCPU и 1 ГБ памяти держат одного-двух пользователей с запасом. Ниже — Ubuntu и вход по SSH ключом под root.

Обновление и файрвол

apt update && apt full-upgrade -y
[ -f /var/run/reboot-required ] && reboot

Файрвол — ufw, надстройка над netfilter (подсистемой фильтрации пакетов в ядре Linux), которая переводит простые правила вида «разрешить 443/tcp» в правила nftables — нынешний формат таблиц netfilter. Открываем только SSH и 443:

grep ^IPV6= /etc/default/ufw     # должно быть IPV6=yes — тогда правила действуют и для IPv6
ufw allow 22/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

UDP открывать не нужно, даже если через прокси пойдут игры: UDP клиента едет внутри того же TCP-соединения на 443 (об этом ниже).

Установка Xray

У проекта есть официальный скрипт установки. Его стоит сначала скачать и посмотреть: он берёт с GitHub последний стабильный релиз Xray-core и базы geoip.dat и geosite.dat, ставит бинарь в /usr/local/bin/xray и создаёт службу systemd под пользователем nobody с одной привилегией — открывать порты ниже 1024.

mkdir -p /root/xray-install && cd /root/xray-install
curl -fsSL -o install-release.sh https://github.com/XTLS/Xray-install/raw/main/install-release.sh
less install-release.sh
bash install-release.sh install
xray version

Обновлять потом той же командой install; только базы — install-geodata. Сам по себе Xray не обновляется.

Ключи

xray x25519               # PrivateKey — в конфиг сервера, Password (PublicKey) — клиентам
openssl rand -hex 8       # shortId: 8 случайных байт в шестнадцатеричном виде
xray uuid                 # UUID первого пользователя

В новых версиях Xray открытый ключ подписан как Password (PublicKey), в старых — Public key. Закрытый ключ никому не показывайте: с ним можно выдать себя за ваш сервер.

Конфиг

/usr/local/etc/xray/config.json:

{
  "log": { "loglevel": "warning", "access": "none" },
  "inbounds": [
    {
      "tag": "vless-reality",
      "listen": "::",
      "port": 443,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "<UUID alice>", "flow": "xtls-rprx-vision", "email": "alice" }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "target": "www.example.com:443",
          "serverNames": [ "www.example.com" ],
          "privateKey": "<закрытый ключ X25519>",
          "shortIds": [ "<shortId>" ]
        }
      },
      "sniffing": { "enabled": true, "destOverride": [ "http", "tls", "quic" ], "routeOnly": true }
    }
  ],
  "outbounds": [
    { "tag": "direct", "protocol": "freedom", "settings": { "domainStrategy": "UseIPv4v6" } },
    { "tag": "block", "protocol": "blackhole" }
  ],
  "routing": {
    "domainStrategy": "IPIfNonMatch",
    "rules": [
      { "ip": [ "geoip:private" ], "outboundTag": "block" },
      { "protocol": [ "bittorrent" ], "outboundTag": "block" }
    ]
  }
}

Разберём по полям.

  • log. warning — только предупреждения и ошибки. access: none выключает журнал доступа: иначе Xray пишет каждое соединение с адресом назначения, и на сервере копится история посещений всех пользователей.
  • listen: "::" — слушать на всех адресах IPv6, а в Linux такой сокет по умолчанию принимает и IPv4 (адреса вида ::ffff:203.0.113.10). Один вход обслуживает оба семейства.
  • clients — пользователи: UUID, поток Vision и метка email. Метка нужна людям и скриптам, Xray её только показывает в журналах.
  • decryption: none — у VLESS нет своего шифрования, его даёт REALITY.
  • realitySettings. target — куда пересылать чужих (в старых версиях поле называлось dest). serverNames — какие SNI считать своими: клиент должен прислать одно из этих имён. privateKey и shortIds — то, о чём шла речь выше; shortId можно выдать несколько, но доступ всё равно решает UUID.
  • sniffing — Xray заглядывает в первые байты соединения клиента и достаёт оттуда имя сайта (из HTTP-заголовка Host, из SNI внутреннего TLS или из QUIC — транспорта HTTP/3 поверх UDP). routeOnly — использовать имя только для правил маршрутизации, а соединяться по адресу, который прислал клиент.
  • freedom — выход «напрямую в интернет». UseIPv4v6 — если клиент прислал имя, резолвить его в A и AAAA и предпочитать IPv4, а если у сайта только IPv6, идти по IPv6. IPv6-адреса назначения уходят по IPv6.
  • blackhole — выход «в никуда». Правила отправляют туда geoip:private (частные сети IPv4 и IPv6: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, fc00::/7, fe80::/10 и другие) — чтобы через ваш прокси нельзя было ходить во внутреннюю сеть хостинга или в ваши собственные сети за сервером, — и BitTorrent: из-за него к провайдеру сервера быстрее всего приходят жалобы.

Проверка и запуск:

chown root:nogroup /usr/local/etc/xray/config.json
chmod 640 /usr/local/etc/xray/config.json     # в конфиге закрытый ключ; nobody читает через группу
xray run -test -config /usr/local/etc/xray/config.json
systemctl enable --now xray
systemctl status xray --no-pager
ss -tlnp | grep :443                            # *:443 users:(("xray",...))

Одна тонкость: Xray определяет формат конфига по расширению файла. Временный файл config.json.tmp он прочитать не сможет — называйте такие файлы что-то.json.

Проверка без ключа

Подключимся к серверу как обычный браузер, но к имени сайта-маски — так, как это сделал бы проверяющий:

curl -sv -o /dev/null --resolve www.example.com:443:203.0.113.10 https://www.example.com/ 2>&1 | grep -E "subject|issuer|SSL connection|HTTP/"
curl -sv -o /dev/null --resolve "www.example.com:443:[2001:db8::10]" https://www.example.com/ 2>&1 | grep -E "subject|issuer|SSL connection|HTTP/"

--resolve заставляет curl соединиться с нашим адресом, но в SNI и в заголовке Host послать имя маски. В ответе должен быть настоящий сертификат www.example.com от публичного удостоверяющего центра, TLS 1.3 и код 200: REALITY не узнал ключа и переслал нас на сайт.

Пользователи

Каждому человеку — свой UUID. Тогда доступ одного можно отозвать, не трогая остальных: убрали его UUID — его клиент дальше рукопожатия не проходит, а ссылки остальных остаются прежними. Менять ключи REALITY нужно только если утёк закрытый ключ сервера — тогда новые ссылки придётся раздать всем.

Руками это правка массива clients и перезапуск. Удобнее небольшой скрипт: он добавляет и удаляет пользователей, перед каждой правкой сохраняет копию конфига, проверяет новый конфиг и, если служба не поднялась, возвращает копию. Положите его в /usr/local/sbin/xray-user и сделайте исполняемым (chmod 755); нужен jq, утилита для JSON.

#!/usr/bin/env bash
# xray-user list | add <name> | del <name> | link <name>
set -euo pipefail

CFG=/usr/local/etc/xray/config.json
TAG=vless-reality
ADDR4=203.0.113.10
ADDR6=2001:db8::10
FP=chrome

die() { echo "xray-user: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root"
inbound='.inbounds[] | select(.tag == $tag)'

check_name() { [[ "$1" =~ ^[A-Za-z0-9._@-]{1,64}$ ]] || die "bad name"; }

apply() {
    local new=$1 backup
    xray run -test -config "$new" >/dev/null || { rm -f "$new"; die "new config does not pass xray run -test"; }
    backup="$CFG.$(date +%Y%m%d-%H%M%S)"
    cp -p "$CFG" "$backup"
    chown root:nogroup "$new"; chmod 640 "$new"; mv "$new" "$CFG"
    systemctl restart xray; sleep 1
    systemctl is-active --quiet xray || { cp -p "$backup" "$CFG"; systemctl restart xray; die "xray did not start, restored $backup"; }
}

links() {
    local id pk pub sni sid q
    id=$(jq -r --arg tag "$TAG" --arg n "$1" "$inbound | .settings.clients[] | select(.email == \$n) | .id" "$CFG")
    [ -n "$id" ] || die "no user $1"
    pk=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.privateKey" "$CFG")
    sni=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.serverNames[0]" "$CFG")
    sid=$(jq -r --arg tag "$TAG" "$inbound | .streamSettings.realitySettings.shortIds[0]" "$CFG")
    pub=$(xray x25519 -i "$pk" | sed -n 's/^Password (PublicKey): //p')
    q="encryption=none&flow=xtls-rprx-vision&security=reality&sni=$sni&fp=$FP&pbk=$pub&sid=$sid&type=tcp&headerType=none"
    echo "vless://$id@$ADDR4:443?$q#$1-ipv4"
    echo "vless://$id@[$ADDR6]:443?$q#$1-ipv6"
}

case "${1:-}" in
    list) jq -r --arg tag "$TAG" "$inbound | .settings.clients[] | .email" "$CFG" ;;
    add)
        [ $# -eq 2 ] || die "usage: xray-user add <name>"; check_name "$2"
        jq -e --arg tag "$TAG" --arg n "$2" "$inbound | .settings.clients[] | select(.email == \$n)" "$CFG" >/dev/null && die "user $2 exists"
        new=$(mktemp --suffix=.json "${CFG%.json}.new.XXXXXX")
        jq --arg tag "$TAG" --arg n "$2" --arg id "$(xray uuid)" \
           '(.inbounds[] | select(.tag == $tag) | .settings.clients) += [{id: $id, flow: "xtls-rprx-vision", email: $n}]' "$CFG" > "$new"
        apply "$new"; links "$2" ;;
    del)
        [ $# -eq 2 ] || die "usage: xray-user del <name>"
        jq -e --arg tag "$TAG" --arg n "$2" "$inbound | .settings.clients[] | select(.email == \$n)" "$CFG" >/dev/null || die "no user $2"
        [ "$(jq --arg tag "$TAG" "$inbound | .settings.clients | length" "$CFG")" -gt 1 ] || die "$2 is the last user"
        new=$(mktemp --suffix=.json "${CFG%.json}.new.XXXXXX")
        jq --arg tag "$TAG" --arg n "$2" \
           '(.inbounds[] | select(.tag == $tag) | .settings.clients) |= map(select(.email != $n))' "$CFG" > "$new"
        apply "$new" ;;
    link) [ $# -eq 2 ] || die "usage: xray-user link <name>"; links "$2" ;;
    *) die "usage: xray-user list | add <name> | del <name> | link <name>" ;;
esac

Пользуются им так:

xray-user add bob        # создаёт UUID и печатает две ссылки: на IPv4 и на IPv6
xray-user list           # alice, bob
xray-user link bob       # показать ссылки ещё раз
xray-user del bob        # отозвать доступ bob; у alice ничего не меняется

xray x25519 -i <закрытый ключ> вычисляет открытый ключ из закрытого — так скрипту не нужно хранить его отдельно. Ссылки — секрет: в них UUID пользователя. Передавайте их человеку тем каналом, которому доверяете.

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

Ссылка vless://

Клиенту не нужно вбивать параметры руками: всё помещается в одну ссылку.

vless://<UUID>@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=<открытый ключ>&sid=<shortId>&type=tcp&headerType=none#alice-ipv4
Параметр Что
<UUID>@адрес:порт пользователь и сервер; IPv6-адрес пишется в квадратных скобках: [2001:db8::10]:443
encryption=none VLESS без своего шифрования
flow=xtls-rprx-vision поток Vision
security=reality транспорт REALITY
sni имя сайта-маски
fp=chrome отпечаток ClientHello, который подделывает uTLS
pbk открытый ключ X25519 сервера
sid shortId
type=tcp&headerType=none обычный TCP без дополнительной обёртки
#… название профиля в клиенте

В ссылке стоит адрес, а не имя сервера: тогда подключение к прокси не зависит от DNS, а запрос имени вашего сервера не светится у провайдера. Ссылок две — на IPv4 и на IPv6: если у одного семейства проблемы, можно переключиться на другое.

Клиент: v2rayN на Windows

v2rayN — клиент для Windows (есть и для Linux и macOS) с графическим интерфейсом и встроенными ядрами: Xray для протоколов и sing-box для режима TUN. sing-box — ещё одно ядро-прокси с открытым кодом, в v2rayN оно отвечает за виртуальный сетевой адаптер.

Установка

На странице релизов github.com/2dust/v2rayN для Windows x64 есть два архива: v2rayN-windows-64.zip (интерфейс на WPF) и v2rayN-windows-64-desktop.zip (интерфейс на Avalonia). Оба портативные, ядра уже внутри; берите первый. Рядом с каждым файлом GitHub показывает его SHA256 — сверьте:

Get-FileHash "$env:USERPROFILE\Downloads\v2rayN-windows-64.zip" -Algorithm SHA256
Expand-Archive "$env:USERPROFILE\Downloads\v2rayN-windows-64.zip" "$env:LOCALAPPDATA\v2rayN"

Портативная версия хранит настройки в своей папке, установщика и службы нет. Запускается v2rayN.exe; для режима TUN — с правами администратора (правой кнопкой → «Запуск от имени администратора»), потому что TUN ставит драйвер сетевого адаптера.

Импорт сервера

Названия пунктов ниже — из английского интерфейса v2rayN 7.x; язык переключается в настройках.

Скопируйте ссылку vless://… в буфер обмена и в главном окне v2rayN нажмите Ctrl+V — это пункт Import Share Links from clipboard в контекстном меню списка серверов. Появится профиль alice-ipv4; вторую ссылку, на IPv6, можно импортировать так же. Правой кнопкой по профилю → Set as active — сделать его активным; Test real delay — v2rayN откроет через прокси тестовую страницу и покажет время ответа. Если вместо времени ошибка — проверьте, что ключ, shortId и SNI в ссылке совпадают с сервером.

Два режима: системный прокси и TUN

Системный прокси. v2rayN поднимает на 127.0.0.1 локальный прокси (HTTP и SOCKS на одном порту, по умолчанию 10808) и прописывает его в настройках прокси Windows — тех, что в «Параметры → Сеть и Интернет → Прокси-сервер». Через туннель идут программы, которые эту настройку читают: браузеры, большинство приложений на .NET и Electron, Microsoft Store. Мимо идут консольные программы — git, curl, Node.js, Python, — клиенты со своими сетевыми настройками, всё, что работает по UDP, а также WSL, Docker и виртуальные машины. Многим консольным программам прокси задаётся переменными окружения:

$env:HTTP_PROXY  = 'http://127.0.0.1:10808'
$env:HTTPS_PROXY = 'http://127.0.0.1:10808'

Плюс режима — он ничего не ломает: службы, которые прокси не используют, продолжают работать как раньше.

TUN. v2rayN через sing-box создаёт виртуальный сетевой адаптер (TUN — интерфейс, который отдаёт программе IP-пакеты вместо провода) и прописывает маршруты так, что весь IP-трафик машины — и TCP, и UDP, IPv4 и IPv6 — уходит в этот адаптер. sing-box собирает из пакетов соединения и отправляет их по правилам маршрутизации: напрямую через настоящий адаптер или в прокси. Это действительно «всё», о чём бы ни знала программа, включая игры на UDP. Цена:

  • нужны права администратора;
  • v2rayN перехватывает запросы DNS, и все имена резолвятся по его правилам — внутренние имена корпоративного или домашнего DNS-сервера перестанут резолвиться, если их не исключить;
  • ошибка в правилах ломает доступ ко всему сразу, а не к одному сайту;
  • другие VPN-клиенты на той же машине могут конфликтовать с маршрутами TUN.

Настройки TUN — Settings → Option Setting, вкладка Tun Mode settings:

  • Auto Route — включено: sing-box сам прописывает маршруты в адаптер;
  • Strict Route — включено: sing-box не даёт трафику, и в первую очередь DNS, уйти мимо адаптера. Windows любит задавать один и тот же вопрос DNS всем резолверам на всех адаптерах сразу; с Strict Route запросы к DNS-серверу роутера или корпоративной сети просто не проходят, и ответы приходят только из туннеля;
  • Stack и MTU — по умолчанию;
  • Enable IPv6 Address — обязательно включить: иначе у адаптера TUN нет IPv6, IPv6-трафик идёт мимо туннеля напрямую, и сайты, доступные по IPv6, видят ваш настоящий адрес. Это классическая «утечка IPv6».

Включается TUN переключателем Enable Tun внизу главного окна или в меню значка в трее; там же выключается — sing-box убирает адаптер и маршруты, и всё возвращается как было. Если v2rayN запущен без прав администратора, TUN не включится: закройте его из трея и запустите снова от имени администратора (или пункт Restart as Administrator). В системе появится адаптер singbox_tun, а в его настройках DNS — адреса 172.18.0.2 и fc00::172:18:0:2: на них отвечает DNS sing-box.

Маршрутизация: два варианта

Правила v2rayN — список условий «такие адреса или имена → такой выход», проверяются сверху вниз, первое совпавшее решает. Выходы: proxy (через сервер), direct (напрямую), block. Условия можно писать адресами и подсетями, доменами и готовыми списками из баз: geoip:… — подсети стран и частные диапазоны из geoip.dat, geosite:… — наборы доменов по категориям из geosite.dat. В v2rayN есть готовые наборы, но встроенный «Global» отправляет напрямую больше, чем кажется по названию: весь geoip:private (кроме трёх частных диапазонов ещё 100.64.0.0/10, 169.254.0.0/16 и другие) и домены geosite:private. Надёжнее завести свой набор:

  1. Положите правила (JSON ниже) в буфер обмена.
  2. Settings → Routing Setting — откроется список «Pre-defined Rule Set List».
  3. В меню окна Rule → Add — откроется окно «Rule Settings» нового набора; в поле Remarks — название, например All via proxy except LAN.
  4. В меню этого окна Rule List → Import Rules From Clipboard, затем Confirm.
  5. В списке наборов выделите новый и нажмите Set as active rule, затем Confirm. Внизу главного окна в списке Routing должен стоять он.

Правило JSON в формате v2rayN — объект с выходом outboundTag и условиями: ip, domain, port, network (tcp, udp). Все условия правила должны выполниться одновременно.

Вариант 1: всё через прокси, кроме локального. Напрямую идут только частные сети (домашняя сеть, корпоративные сети за VPN, локальные виртуальные машины), а для IPv6 — link-local fe80::/10: эти адреса за пределы сегмента не маршрутизируются, и на них работает сам IPv6 на адаптере (Neighbor Discovery из четырнадцатой части). Multicast ff00::/8 и 224.0.0.0/3 v2rayN в режиме TUN отбрасывает сам, отдельным встроенным правилом. Всё остальное — в прокси:

[
  { "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" },
  { "remarks": "Private ranges direct", "outboundTag": "direct",
    "ip": [ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fe80::/10" ] },
  { "remarks": "Everything else via proxy", "outboundTag": "proxy", "port": "0-65535" }
]

Первое правило блокирует QUIC — HTTP/3 поверх UDP на порт 443: браузер сразу переходит на HTTPS по TCP, и тот идёт через Vision с маскировкой (так сделано и во встроенных наборах v2rayN). Игр это не касается, у них другие порты. Последнее правило по порту 0-65535 совпадает с чем угодно — это «всё остальное». Если в локальной сети есть внутренние имена (server.corp.example.com), а DNS идёт через прокси, они резолвиться не будут: в этом варианте внутренние ресурсы доступны только по IP. Если это мешает, добавьте в первое правило "domain": [ "domain:corp.example.com" ] и направьте эти имена на локальный DNS в настройках DNS.

Вариант 2: только нужное. Через прокси — выбранные сайты и сервисы, остальное напрямую, со скоростью и задержкой вашего провайдера:

[
  { "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" },
  { "remarks": "Private ranges direct", "outboundTag": "direct", "ip": [ "geoip:private" ] },
  { "remarks": "Selected sites via proxy", "outboundTag": "proxy",
    "domain": [ "domain:example.org", "domain:example.net", "geosite:google" ] },
  { "remarks": "Everything else direct", "outboundTag": "direct", "port": "0-65535" }
]

domain:example.org совпадает с самим доменом и всеми поддоменами. Вместо отдельных доменов можно брать категории geosite — например, наборы доменов конкретного сервиса; список категорий есть в репозитории баз, которыми пользуется ваш клиент. Минус варианта — список надо вести: новый заблокированный сайт не откроется, пока вы его не добавите.

DNS

В варианте 1 и в режиме TUN DNS тоже должен идти через прокси, иначе провайдер видит имена всех сайтов в запросах DNS, хоть сами соединения и спрятаны. Настройки — Settings → DNS Settings, вкладка Basic DNS Settings:

  • Remote DNS — резолвер, к которому v2rayN ходит через прокси. Лучше DoH, DNS over HTTPS — запросы DNS внутри обычного HTTPS к резолверу, — и по адресу, а не по имени: https://1.1.1.1/dns-query. Тогда, чтобы найти сам резолвер, ничего резолвить не нужно.
  • Domestic DNS — для имён, которые идут напрямую. В варианте 1 таких нет, но поле всё равно стоит поправить: по умолчанию в нём и в Bootstrap DNS стоит китайский публичный резолвер 119.29.29.29, у v2rayN китайские корни. Поставьте 1.1.1.1 или резолвер своего провайдера.
  • Bootstrap DNS — им v2rayN резолвит имена самих DNS-серверов, если они заданы именем. С адресом в Remote DNS он почти не нужен, но 119.29.29.29 там тоже лучше заменить.
  • FakeIP — оставить выключенным: с ним приложения получают вместо настоящих адресов фиктивные из 198.18.0.0/15, а это лишняя сложность.

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

Проверить, кто отвечает на ваши запросы, можно специальными именами, которые возвращают адрес спросившего резолвера:

Resolve-DnsName whoami.akamai.net -Type A                  # адрес резолвера, который пришёл к Akamai
Resolve-DnsName o-o.myaddr.l.google.com -Type TXT          # то же у Google

В обоих ответах должны быть адреса резолвера из Remote DNS (для 1.1.1.1 — адреса Cloudflare), а не провайдера и не вашего роутера.

Route Only и IPv6

Здесь есть ловушка, на которую я наступил сам. В режиме TUN sing-box передаёт соединения во встроенный Xray, а у того по умолчанию включён sniffing — он достаёт из соединения имя сайта и подменяет им адрес назначения. На сервер уходит не «соединись с IPv6-адресом сайта», а «соединись с example.com», и сервер резолвит имя сам и выбирает IPv4. Утечки нет, но curl -6 покажет IPv4-адрес сервера, а приложения, которые сознательно выбрали IPv6, выйдут по IPv4.

Лечится галкой Route Only в Settings → Option Setting → Core: basic settings (Turn on Sniffing оставить включённым). С ней имя нужно только правилам маршрутизации, а соединение идёт на тот адрес, который выбрало приложение — это то же routeOnly, что и в конфиге сервера.

UDP и игры

UDP клиента VLESS везёт внутри того же TCP-соединения: каждый пакет UDP упаковывается с длиной и адресом (формат XUDP — расширение мультиплексора Xray для UDP) и едет в потоке. На сервере пакет распаковывается и уходит в интернет уже настоящим UDP. Поэтому на сервере не нужно открывать UDP-порты, и DPI не видит отдельного UDP-потока.

Минус — свойство TCP из десятой части: если потерялся один сегмент, все данные за ним ждут повторной передачи. Для UDP-приложения это значит, что одна потеря задерживает все пакеты после неё, а не только потерянный. В играх это заметно как рывки при плохой связи; к пингу ещё добавляется задержка до сервера. Для игр выбирайте сервер поближе.

Ещё одна особенность: Vision по умолчанию отбрасывает UDP на порт 443, то есть QUIC и HTTP/3 (статья про HTTP/3). Браузер, не дождавшись ответа по QUIC, переходит на обычный HTTPS по TCP, и тот уже идёт через Vision с его маскировкой; правило block для UDP 443 в наборах выше просто ускоряет этот переход.

Проверить, что UDP действительно идёт через сервер, можно запросом STUN — протокола, которым программы звонков узнают свой внешний адрес: клиент шлёт по UDP короткий запрос Binding, сервер STUN отвечает, с какого адреса и порта он этот запрос увидел (атрибут XOR-MAPPED-ADDRESS). Через TUN ответ должен назвать адрес вашего прокси-сервера, по IPv4 и по IPv6 отдельно. Готовые веб-страницы проверки WebRTC делают то же из браузера.

Ноутбук в кафе

Всё то же работает на ноутбуке в чужой сети, с тремя оговорками.

  • Страница входа в Wi-Fi. Пока вы не вошли через страницу кафе (captive portal), интернета нет, а с включённым TUN DNS идёт через прокси, и страница входа не откроется. Порядок: выключить TUN, войти в Wi-Fi, включить TUN.

  • Частные диапазоны — уже не ваши. Правило «напрямую 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12» дома ведёт в вашу сеть, а в кафе — в сеть кафе. Ноутбук, который привык к домашним или корпоративным адресам, будет стучаться к ним по SMB или Kerberos — и ответить может любое устройство в кафе с таким адресом, в том числе чтобы перехватить попытку входа. Для чужих сетей заведите второй набор правил — всё через прокси без исключений:

    [
      { "remarks": "Block QUIC (UDP 443)", "outboundTag": "block", "port": "443", "network": "udp" },
      { "remarks": "Link-local direct", "outboundTag": "direct", "ip": [ "fe80::/10" ] },
      { "remarks": "Everything via proxy", "outboundTag": "proxy", "port": "0-65535" }
    ]
    

    Частные адреса тогда уходят на сервер, а он отправляет их в blackhole: в сеть кафе не попадает ничего. Переключаться между наборами — списком Routing внизу главного окна.

  • Отдельный пользователь на каждое устройство. Ноутбуку — свой UUID (xray-user add alice-laptop). Потеряли ноутбук — отзываете только его, остальные устройства работают как работали.

IPv6 в кафе может не быть вовсе — это не мешает: подключение к серверу идёт по IPv4-ссылке, а IPv6-сайты приложения получают через туннель, с IPv6-адреса сервера.

Смотрим руками

Что видит провайдер. Включите прокси, откройте Wireshark (или pktmon, встроенный в Windows сниффер) на настоящем адаптере и отфильтруйте первые сообщения TLS к серверу:

ip.addr == 203.0.113.10 && tls.handshake.type == 1

В ClientHello: SNI www.example.com, ALPN h2, http/1.1, key_share с X25519, набор расширений как у Chrome — и 32 «случайных» байта в session_id. Затем ServerHello и дальше только зашифрованные записи Application Data. Ничего, что отличало бы это от визита на www.example.com, кроме адреса сервера.

Что видит проверяющий. Команды curl --resolve из раздела о сервере — с любой машины, без ключа: настоящий сайт с настоящим сертификатом.

Через какой адрес вы выходите. С включённым TUN:

curl.exe -4 https://ifconfig.co     # 203.0.113.10
curl.exe -6 https://ifconfig.co     # 2001:db8::10

Консольный curl — хорошая проверка режима TUN: в режиме системного прокси он без переменных окружения пошёл бы напрямую и показал бы ваш настоящий адрес. Сайты проверки IPv6 и утечек DNS в браузере должны показать адреса сервера и резолвер, через который идёт удалённый DNS, а не провайдерский.

Что видит сервер. На сервере при выключенном журнале доступа Xray ничего не пишет о посещённых адресах. Для отладки временно включите access в log (без пути журнал пойдёт в journald) и смотрите journalctl -u xray -f: каждое соединение с меткой пользователя и выходом — [vless-reality >> direct] или [vless-reality -> block] для частных адресов. Не забудьте выключить обратно.

Ограничения

  • Адрес. Маскировка не защищает от блокировки по IP. Если адрес сервера попал в список, нужен новый адрес — у большинства облаков это минута.
  • Несоответствие SNI и адреса. SNI большого сайта на адресе VPS-хостинга — косвенный признак. Сам по себе он мало что доказывает (так выглядят и обычные обратные прокси), но в сочетании с объёмом трафика может привлечь внимание.
  • Объёмы. Многочасовое соединение с гигабайтами на «главную страницу сайта» — тоже статистика. Для обычного пользования это не проблема, для раздачи прокси сотням людей — да.
  • Обновления. Методы DPI меняются, и Xray меняется вместе с ними: обновляйте ядро на сервере и клиент. Несовместимые изменения формата бывают, поэтому сервер и клиент лучше держать на версиях одного времени.
  • Ключи. Закрытый ключ X25519 сервера и UUID пользователей — секреты. Утекла ссылка одного пользователя — удалите его UUID и выдайте новый. Утёк закрытый ключ — новая пара ключей и новые ссылки всем.

Итог: Xray принимает соединения на 443, VLESS отвечает за то, кто вы и куда идёте, Vision — за то, чтобы TLS внутри TLS не был виден по размерам, а REALITY — за то, чтобы и снаружи, и при проверке подключением сервер выглядел как настоящий большой сайт. А на клиенте TUN-режим v2rayN заворачивает в это весь трафик машины, включая UDP и IPv6.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.