Клиент VLESS/Reality#
Этот документ описывает встроенный клиент VLESS/Reality, который есть только в пакете
steer-vless (он зависит от steer-core той же версии).
Зачем он вообще#
Готовые клиенты — Xray, sing-box — занимают десятки мегабайт и на роутер с overlay-разделом в
6-7 МБ не встают. Здесь всё расширенное ядро — steer-core (основа, общая libsteer.so и
криптография), этот клиент (бинарник steer-vless), звезда xsteer, обфускатор и мост Telegram —
около 3,1 МБ под mipsel_24kc без сжатия (около 1,3 МБ в пакетах; сборка 2.0.0), потому что клиент написан под то же ограничение, что и всё
ядро: работать на нижней границе домашних роутеров.
Клиент сам создаёт TUN-устройство, сам делает рукопожатие TLS 1.3 (своя реализация протокола,
примитивы — wolfSSL за слоем src/lib/scrypto.h) и сам
несёт трафик. Дальше это устройство ничем не отличается от wg0 для остальной части ядра: метки,
таблицы, каналы и failover работают с ним ровно так же.
Что поддерживается#
- Транспорты:
tcp,grpc,xhttp(два последних — через встроенный минимальный HTTP/2),wsиhttpupgrade(запрос Upgrade по HTTP/1.1 — см. ws и httpupgrade). - Безопасность:
reality,tls(с проверкой цепочки сертификата и имени),none. Любая из трёх сочетается с любым транспортом. - Поток:
xtls-rprx-visionили без него (уwsиhttpupgrade— только без него: как и у Xray, Vision там не бывает, такой узел пропускается с причиной). - Постквантовая часть: гибрид X25519MLKEM768 в TLS 1.3 и REALITY, проверка подписи ML-DSA-65 у
REALITY (
pqv), шифрование VLESSmlkem768x25519plus— см. Постквантовая часть. - Трафик: TCP и UDP через TUN — см. UDP и QUIC.
- Идентификатор: UUID или короткая строка, из которой UUID выводится — см. Идентификатор пользователя.
ICMP не пересылается. Успешный ping через прокси не означал бы работающий путь — это
вводило бы в заблуждение, — поэтому всё, что не TCP и не UDP, получает ICMP «порт недостижим».
Идентификатор пользователя#
id в ссылке vless://id@host:port — это НЕ обязательно UUID. Правило то же, что в Xray
(common/uuid/uuid.go, ParseString), и оно смотрит на длину строки:
| Длина | Что делает клиент |
|---|---|
| 32-36 знаков | Разбирает как шестнадцатеричный UUID: группы 8-4-4-4-12, дефисы между группами необязательны, регистр любой |
| 1-30 знаков | Выводит UUID: sha1(16 нулевых байт ‖ строка), первые 16 байт, дальше версия 5 в старшей половине байта 6 и вариант RFC 4122 в байте 8 |
| 0, ровно 31, больше 36 | Отказ: такая строка не может стать 16 байтами ни одним из двух путей |
Панели выдают оба написания, и короткое имя вроде TMG_74317ba5f91 — законный узел, а не ошибка
подписки: сервер выводит из такой же строки те же 16 байт. Проверить вывод легко —
TMG_74317ba5f91 обязан давать dd4748e6-1f48-5b36-bbc6-656b42ccfd75.
Порядок правил важен именно в этом виде: строку из 15 шестнадцатеричных знаков Xray выводит, а не разбирает как обрезанный UUID. Клиент, решивший иначе, отправит другие 16 байт — сервер такого пользователя не найдёт и просто закроет соединение, ничего не сообщив.
Непригодный id называется при разборе подписки, а не при подключении: узел получает причину
(идентификатор пуст, идентификатор: 31 знак, нужен UUID, идентификатор длиннее UUID,
UUID с недопустимым знаком), в список кандидатов не попадает и попыток сторожа не тратит.
Формат подписки#
Форму определяет первый непробельный знак и наличие ключа proxies:; base64 раскодируется, если в тексте
нет :// и это не JSON и не Clash.
| Форма | Что читается |
|---|---|
список ссылок vless:// (обычный или base64) |
все параметры: type, security, sni, fp, pbk, sid, flow, path, host, serviceName, mode, extra, headerType, encryption, pqv; процентное кодирование, IPv6 в скобках, эмодзи в имени |
| конфиг Xray (массив или один объект) | outbounds с protocol: vless: vnext/users и упрощённая форма settings.{address,port,id,flow,encryption}; streamSettings (network, security, realitySettings с mldsa65Verify, tlsSettings, wsSettings, httpupgradeSettings, grpcSettings, xhttpSettings, tcpSettings.header) |
| конфиг sing-box | outbounds с type: vless: server, server_port, uuid, flow, tls (server_name, utls, reality), transport (ws, httpupgrade, grpc; max_early_data с early_data_header_name: Sec-WebSocket-Protocol дают ?ed=) |
| Clash / Mihomo YAML | proxies с type: vless, блоком и потоком {…}: network, tls, servername, client-fingerprint, flow, encryption, reality-opts, ws-opts (включая v2ray-http-upgrade), grpc-opts, xhttp-opts; якоря & отбрасываются, алиасы * пропускаются |
Узел, который клиент не умеет, но который сервер требует, получает причину при разборе:
tcp headerType=http, обфускация xhttp (downloadSettings, размещения sessionID/seq/данных,
xPaddingObfsMode), encryption вне mlkem768x25519plus, pqv не длиной 1952 байта, слишком много
разных длинных значений. flow=xtls-rprx-vision-udp443 читается как xtls-rprx-vision. Длинные
значения (encryption, pqv) хранятся не в узле, а в общей таблице sub_intern (256 записей,
одинаковые значения — один экземпляр); в списке узлов (vless-nodes) видны режим шифрования и признак
pqv, ключи не выводятся.
Какие из пригодных узлов брать в кандидаты, решает выход: nodes (номера), transport (транспорт
узла), exclude (страна по флагу-эмодзи в имени) и exclude_name (кусок имени) —
spec-v2.md. Исключение номера не сдвигает; страну узла vless-nodes печатает полем cc.
Как работает Reality#
Reality аутентифицирует клиента внутри изменённого рукопожатия TLS 1.3:
- У сервера есть постоянная пара ключей X25519, публичная половина (
pbk) известна клиенту. - Клиент генерирует эфемерную пару и кладёт свою публичную половину в
key_shareвнутриClientHello. - Из эфемерного приватного ключа клиента и постоянного
pbkсервера считается общий секрет. Из него выводится аутентификатор, который прячется вsession_id. - Признал аутентификатор — сервер работает как прокси VLESS. Не признал — прозрачно проксирует маскировочный сайт, и клиент не может отличить это от обычного сайта.
Ключевой инвариант. ClientHello обязан быть неотличим от браузерного (Chrome) — вплоть до
порядка расширений, набора шифров и значений GREASE. Любое отклонение становится признаком, по
которому клиента опознают. Именно поэтому здесь свой сборщик Hello, а не то, что предлагает
библиотека: библиотечный Hello узнаваем.
Аппаратный AES используется, когда он есть; на MIPS и ARM без него выбирается ChaCha20: на таком
железе программный AES-GCM в разы медленнее, а потолок скорости задаёт именно шифрование
(STEER_CIPHER переопределяет выбор, src/proto/tls/reality.c).
Поток Vision#
xtls-rprx-vision скрывает признаки «TLS внутри TLS», подмешивая кадры набивки случайной длины.
- Кадры набивки скрывают длину соседних записей с данными.
- Заголовок VLESS вставляется в поток перед первым кадром Vision.
- Протокол симметричен: первый ответ сервера тоже начинается с UUID.
Разбор идёт потоком: кадр не обязан приехать целиком за одну запись TLS, и границы записи и кадра совпадать не должны. Начало потока и заголовок кадра копятся между вызовами — иначе запись, обрывающаяся на середине заголовка, теряла бы синхронизацию, и дальше клиенту как данные уезжали бы служебные байты.
ws и httpupgrade#
Оба начинаются одинаково: GET по HTTP/1.1 с Connection: Upgrade и Upgrade: websocket, ответ
101. Дальше у ws поток идёт кадрами WebSocket (RFC 6455), у httpupgrade — как есть, без кадров.
Параметры узла — как у Xray: в ссылке type=ws|httpupgrade, path и host; в конфиге Xray в
подписке — wsSettings/httpupgradeSettings с path, host и headers (network может быть и
websocket).
- Эталон — Xray-core. Где Xray и sing-box расходятся (заголовки, User-Agent, ALPN, ранние данные), делается как у Xray.
- Запрос — как у клиента Xray, до байта: тот же набор заголовков облика Chrome, тот же регистр
имён и порядок (порядок задаёт net/http Go —
Host,User-Agent, затем по алфавиту). СвойUser-Agentузла словомfirefox,safari,edge,curlилиgolangдаёт облик этого клиента, как у Xray. Отличие одно — номера версий браузеров: у Xray они считаются от даты, у нас зашиты (Chrome — тот же, что у xhttp). - Host —
hostузла, иначеsni, иначе адрес узла. - Путь проходит те же преобразования, что у Xray:
?ed=Nвырезается, оставшийся запрос пересобирается по алфавиту; уhttpupgrade?уходит как%3F. Путь, на котором Xray споткнулся бы (уws— битая%-последовательность), делает узел непригодным с причиной. - Ранние данные (
?ed=N) — как у Xray. Уwsзапрос Upgrade уходит первой записью: запись не длиннееNедет в самом запросе, вSec-WebSocket-Protocol(base64url без=), длиннее — кадрами после ответа 101; записи, сделанные до ответа, уходят сразу за ним. Уhttpupgradeповерхtlsиrealityданные идут вслед за запросом, не дожидаясь ответа; без TLS ответ ждём — сервер Xray теряет данные, приехавшие одним сегментом с запросом. Сервер sing-box принимает ранние данные ws, только если у него заданоearly_data_header_name: Sec-WebSocket-Protocol. - ALPN — только
http/1.1(уtlsи уreality), как у Xray: сh2в списке сервер за TLS вправе выбрать HTTP/2, и запрос HTTP/1.1 для него — мусор. Выбрал неhttp/1.1— отказ «сервер выбрал не HTTP/1.1». - Кадры отправки — по 4096 байт, как у Xray (буфер gorilla/websocket): запись длиннее режется на binary без FIN и continuation, каждый кадр — своя запись TLS, у каждого своя маска. На приёме — фрагменты, ping (ответ pong), close (ответный close и конец потока); нарушения RFC 6455 рвут соединение. При закрытии уходит close 1000, как у Xray.
- Отказы названы: ответ не 101 (с кодом: «сервер ответил 404 вместо 101 — проверьте path и
host»), 101 без
Upgrade, неверныйSec-WebSocket-Accept, нет ответа за срок соединения.
Проверка сертификата узла#
У security=tls подлинность узла доказывает сертификат. По умолчанию — цепочка до корней из
ca-certificates.crt, имя из sni (нет sni — адрес узла), срок и подпись CertificateVerify.
Правила Xray-core поверх этого:
pcs(pinnedPeerCertSha256) — SHA-256 сертификата (DER) в hex, через запятую; двоеточия OpenSSL и заглавные буквы допустимы. Совпал лист — узел принят без проверки цепочки, срока и имени. Совпал промежуточный сертификат или корень, присланный сервером и являющийся CA, — цепочка проверяется до него, а не до системных корней (имя —vcnилиsni). Не совпало ничего — отказ «отпечаток сертификата не закреплён (pcs)». Корень, которого сервер не присылает, закрепить нельзя — как у Xray.vcn(verifyPeerCertByName) — имена через запятую, против которых проверяется цепочка вместоsni; годится любое.- Подпись CertificateVerify проверяется во всех случаях, в том числе при закреплённом листе.
allowInsecure(ссылкаallowInsecure=1илиinsecure=1,allowInsecureвtlsSettingsконфига Xray,insecureвtlssing-box,skip-cert-verifyClash) подпиской не принимается: узел пропускается с причиной «allowInsecure: включите insecure у выхода явно». Отказ от проверки включается только ключомinsecure: trueу выхода (spec-v2.md); тогда цепочка, имя, срок и закрепления не проверяются (остаётся подпись CertificateVerify), аsteer diagпишет предупреждение,steer status—"insecure":true. Номера узлов считаются среди пригодных, поэтому у такого выхода они включают узлы сallowInsecure; перечень по файлу подписки с теми же номерами даётsteer vless-nodes /путь --insecure(иvless-probe /путь --insecure), а узел сallowInsecureнесёт в нём"insecure":true(contract-v1.md, раздел 6).
В подписках отпечаток читается из pcs ссылки, pinnedPeerCertSha256/verifyPeerCertByName в
tlsSettings конфига Xray, fingerprint Clash (SHA-256 сертификата) и certificate_public_key_sha256
sing-box (SHA-256 от SubjectPublicKeyInfo листа в base64; совпал — узел принят так же, как при
закреплённом листе). У reality эти поля ничего не значат.
ECH (Encrypted Client Hello)#
У узла security=tls с ECHConfigList в ссылке (ech=, echConfigList в tlsSettings конфига Xray,
ech-opts.config Clash, ech.config sing-box; значение — base64) настоящее имя сервера уходит
зашифрованным. По проводу идёт внешний ClientHello с именем public_name из ECHConfig, настоящий
(внутренний) лежит в его расширении, запечатанный HPKE ключом сервера: DHKEM(X25519, HKDF-SHA256),
HKDF-SHA256, AES-128-GCM или ChaCha20-Poly1305 — первый из этих наборов в списке; подходят записи
версии 0xfe0d без обязательных расширений. Внутренний Hello — тот же, что без ECH (облик Chrome,
гибридный ключ ML-KEM), без GREASE-расширения ECH и с supported_versions только от TLS 1.3; дополняется
нулями по maximum_name_length до кратного 32. Сжатия расширений (ech_outer_extensions) нет, поэтому
внешний Hello примерно вдвое больше обычного.
Принял ли сервер ECH, видно по восьми последним байтам ServerHello.random (подтверждение принятия). Принял —
ключи и Finished считаются по внутреннему Hello, сертификат проверяется по настоящему имени. Не принял (сервер
не знает ECH, ключ в ссылке устарел или чужой) — рукопожатие обрывается сразу с причиной «сервер не принял ECH»:
продолжать значило бы выдать имя открытым текстом. Значение вида домен+https://сервер (Xray сам спрашивает
запись из DNS) и испорченный список — узел пропускается с названной причиной, а не уходит без ECH. У
security=reality и none поле игнорируется.
Не поддержано: получение ECHConfigList из DNS (HTTPS-запись), HelloRetryRequest при ECH, GREASE-ECH без
конфигурации, ECH у DoT, DoH, DoQ и Reality. Сверено с Xray-core 26.9.9 (сервер на crypto/tls Go: оба
набора шифров, сервер без ECH, чужой ключ) и с Cloudflare (cloudflare-ech.com).
Постквантовая часть#
Гибрид X25519MLKEM768. ClientHello при security=tls и reality предлагает группу 0x11EC:
в key_share идут пустая GREASE-группа, гибридный ключ (ключ инкапсуляции ML-KEM-768, 1184 байта, и
открытый X25519, 32 байта) и отдельный X25519 с тем же эфемерным ключом; в supported_groups —
GREASE, 0x11EC, x25519, secp256r1, secp384r1. По составу это Chrome 133 (uTLS HelloChrome_Auto):
17 расширений, 1757 байт; порядок расширений перемешивается на каждом соединении. Если сервер
выбрал гибрид, ServerHello несёт шифротекст ML-KEM (1088 байт) и X25519 сервера (32), секрет
расписания ключей — ML-KEM ss ‖ X25519 ss (порядок draft-ietf-tls-ecdhe-mlkem). Если выбрал
обычный X25519 — расписание прежнее. Ключ аутентификатора REALITY считается по X25519-половине, как у
Xray. Сервер REALITY выпуска 26.9 (xtls/reality, tls.go) признаёт клиента, только если в Hello есть
гибридный share, причём перед необязательным X25519; Hello без него он отдаёт на маскировочный сайт
(проверено: такой клиент получает «узел не признал ключ»). Поэтому гибрид включён по умолчанию;
STEER_NOPQ=1 выключает его для разбора (посредник, роняющий Hello больше
одного сегмента). Замороженные байты обоих вариантов — tests/chello-frozen.h и
tests/chello-frozen-pq.h; состав против uTLS — tests/hellostruct.py, эталон снимает
tests/utls/hello.go.
pqv (mldsa65Verify). Открытый ключ ML-DSA-65 (1952 байта, base64url — 2603 знака) в ссылке,
mldsa65Verify или pqv в realitySettings конфига Xray и reality-opts Clash. Сервер REALITY с
mldsa65Seed кладёт в единственное расширение временного сертификата подпись (3309 байт) над
HMAC-SHA512(authkey, ed25519_pub ‖ ClientHello ‖ ServerHello) (оба сообщения — целиком, с заголовком
рукопожатия). Если у узла задан ключ, подпись обязана быть и сойтись, иначе узел не признан («подпись
ML-DSA-65 сервера не сошлась с pqv узла»). Без ключа подпись не проверяется — как у Xray. Сервер с
mldsa65Seed требует маскировочного сайта с большим сертификатом (его сообщение сертификата
подгоняется под размер настоящего).
VLESS encryption. Строка encryption узла (mlkem768x25519plus.<режим>.<1rtt|0rtt>.<набивка>.<ключ>…)
включает шифрование между транспортом и запросом VLESS (src/proto/transport/trvenc.c; для
транспорта оно невидимо, transport_write/transport_read идут через него). Правила разбора —
src/proto/transport/vencp.h, те же, что у Xray:
| Часть | Значение |
|---|---|
| режим | native — записи как есть; xorpub — открытые ключи рукопожатия замаскированы гаммой AES-CTR; random — сверх того замаскированы пятибайтные заголовки всех записей |
1rtt / 0rtt |
0rtt разрешает продолжать по билету сервера; билет — в таблице процесса (16 записей, ключ — хеш строки), срок задаёт сервер |
| набивка | токены короче 20 знаков вида 100-111-1111 (вероятность, от, до), чередуются: длина, пауза, длина…; нет — умолчание Xray |
| ключ | токен от 20 знаков, base64url: 32 байта — X25519, 1184 — ключ ML-KEM-768; несколько — цепочка реле |
Рукопожатие 1-RTT: iv(16) ‖ реле… ‖ AEAD(длина) ‖ AEAD(ML-KEM ek ‖ X25519 pub) ‖ набивка туда,
AEAD(ML-KEM ct ‖ X25519 pub) ‖ AEAD(билет) ‖ AEAD(длина набивки) ‖ набивка обратно. Ключи AEAD —
BLAKE3.derive_key(контекст, ключевой материал), для записей материал — pfsKey ‖ nfsKey, а контекст
своего направления — сообщение, которое его создало. Шифр записей — AES-256-GCM при аппаратном AES,
иначе ChaCha20-Poly1305 (STEER_CIPHER действует и здесь); сервер пробует оба. Записи: 23 3 3 <длина>,
до 8192 байт данных, nonce — счётчик. Билет 0-RTT сервер отклоняет шумом: клиент выбрасывает
билет и отвечает «сервер отклонил билет 0-RTT», следующее соединение идёт полным рукопожатием.
Отправка записи не сдвигает состояние, пока транспорт не принял байты (H2_EWINDOW повторяет те же
байты). Vision поверх шифрования работает потоком, без прямого копирования. Значение none, пустое и
отсутствие параметра — шифрования нет; всё, что не разобралось по правилу выше, делает узел
непригодным («encryption не поддержан»). Трасса: STEER_VENC_TRACE=1 (режим, шифр, билет или полное
рукопожатие), STEER_PQ_TRACE=1 (сервер выбрал гибрид).
TUN и обработка TCP#
Ядро работает с сырыми IP-пакетами, а сервер VLESS ждёт поток TCP, поэтому внутри есть небольшая машина состояний TCP.
- Ретрансмиссии. Кольцевой буфер хранит неподтверждённые байты. Повтор запускается по таймауту или по дублирующим ACK (RFC 5681).
- Буфера переупорядочивания нет. Пакеты клиента, пришедшие не по порядку, отбрасываются — их перешлёт ОС клиента. Это экономит память роутера, а платит за это только тот клиент, у которого и так теряются пакеты.
- Многопоточность.
IFF_MULTI_QUEUEдаёт по очереди на ядро (до четырёх). Симметричное хэширование кладёт обе половины одного соединения TCP в один поток, поэтому на пути данных не нужны блокировки. - Память по факту. Выделяется на соединение, до 320 одновременных; горячая часть состояния соединения умещается в две кэш-линии, холодная лежит отдельно и берётся страницами по обращению.
Узлы выхода: сколько сразу и как за ними следят#
Узел выхода выбирается при подъёме: nodes (или все пригодные узлы подписки), отфильтрованные
transport, проверяются по порядку — соединение, TLS/Reality, запрос через узел и первый байт ответа,
до 8 секунд на узел, — и первый ответивший становится узлом выхода (узел, названный одним номером,
не проверяется). Ключ active выхода (docs/spec-v2.md, «Пул узлов туннеля») держит
активными сразу несколько узлов: остальные active - 1 клиент находит среди кандидатов по порядку
сам, уже с поднятым устройством, так что подъём их проверок не ждёт. Новое соединение получает узел
по by (connection — случайный живой, поровну; site — по адресу назначения; site_client — по
клиенту и адресу) и остаётся на нём до конца; запасные сессии пула — к своему узлу, и при site
соединению достаётся только запасная его узла.
Слежка — за каждым активным узлом, в том же процессе и под демоном, и без него:
- раз в
interval(60 с) — та же проверка, что при подъёме; неудача — повтор через 3 с, две подряд — узел мёртв (проверке, которую позвал обрыв соединения поsilence, хватает одной неудачи: узел и так молчал дольше порога); - раньше срока проверку зовут три несостоявшихся подряд соединения с узлом и обрыв живого
соединения: связь с узлом, по которой отправленное не подтверждено дольше
silence(20 с) или которая молчит дольшеsilenceи не отвечает на проверку TCP keepalive, ядро обрывает (TCP_USER_TIMEOUT,SO_KEEPALIVE). Это узел, умерший под закачкой, звонком или игрой, когда новых соединений нет; долгий опрос и простаивающее соединение не задеваются — живой узел подтверждает принятое и отвечает на keepalive сам; - мёртвый узел: его соединения сбрасываются (RST клиенту — приложение переподключается сразу), и его место занимает следующий свободный кандидат по порядку предпочтения, без перезапуска процесса; соединения живых узлов не трогаются. Свободного нет — круг через 15 с (сперва прежний узел, потом остальные), каждый пустой круг вдвое дольше, до 5 минут;
- связь, оборванная ядром или сброшенная узлом, — RST клиенту, а не FIN: FIN значил бы «ответ
кончился», и обрезанная закачка выглядела бы целой. Неудачная отправка узлу — тоже RST, а данные
на соединение, которого в таблице нет (клиент выхода перезапущен), получают RST по правилу TCP —
приложение не ждёт своего таймаута на соединении, которого больше нет. Последнее — при одном потоке
цикла (умолчание;
STEER_TUN_THREADSниже): при нескольких половина соединения бывает в чужой очереди, и RST убил бы живое.
Демону клиент говорит up с именем устройства, когда оно поднято, down с причиной последней
неудачной проверки — когда живых узлов не осталось, и снова up, когда живой нашёлся
(docs/ctl.md). Какие узлы активны — объект vless у выхода в steer status:
"vless": { "pid": 2716, "up": true, "node": "NL-1", "want": 2, "slots": 2, "by": "site",
"active": [ { "index": 3, "name": "NL-1" }, { "index": 7, "name": "DE-2" } ] }
index — номер узла среди пригодных (тот же, что у steer vless-nodes и в nodes), node — имя
первого активного, want — active спеки, slots — сколько держится (не больше кандидатов), up —
есть живой узел. Объекта нет — клиент не запущен. Те же номера — в поле active команды helper
демона; steer diag предупреждает (id nodes), пока активных меньше active.
UDP и QUIC#
UDP едет как команда VLESS 2: заголовок запроса называет один адресат, а дальше поток — это
последовательность датаграмм, каждая с двухбайтовой длиной в начале. Обе стороны используют одну
рамку. Именно это несёт QUIC (HTTP/3), WireGuard/WARP, игровой трафик и обычный DNS. У узла с
flow=xtls-rprx-vision UDP идёт иначе — Mux.Cool с рамками XUDP, см. ниже.
- Одна сессия к узлу на поток. Адресат живёт в заголовке запроса, то есть фиксирован на всё
время потока: сессия на пару
источник:порт → адресат:порт— это то, что диктует форма протокола. XUDP (Mux.Cool для UDP) у Xray умеет мультиплексировать несколько адресатов в один поток, и выигрывает это ровно там, где адресатов много, а датаграмм на каждого одна — то есть на DNS. Для того, ради чего UDP здесь нужен (QUIC, WireGuard, игры), адресат один, а поток живёт минутами, поэтому XUDP добавил бы свою рамку и ничего больше. - Узел без
flow: команда 2, Vision нет. Vision заменяет копирование потока после рукопожатия, для датаграмм это лишено смысла,flowв запросе не объявляется. Такой запрос принимают все серверы. - Узел с
flow=xtls-rprx-vision: Mux.Cool и XUDP, как у клиента Xray. Сервер не принимает UDP-запрос (команду 2) к учётной записи с vision: Xray-core отвечает «doesn't support UDP», sing-box сверяет flow записи с flow запроса для любой команды и отвергает и пустой («flow mismatch»), и vision («flow does not support UDP»). Собственный клиент Xray в этом случае (proxy/vless/outbound) заменяет команду на Mux (3), и здесь так же: заголовок запроса — команда 3 сflow, без порта и адреса (службаv1.mux.cool:666подразумевается командой); дальше поток обёрнут Vision, как у TCP, а внутри — рамки Mux.Cool (common/mux,common/xudp):[длина метаданных u16][сессия u16 = 0] [статус][опции]…[длина данных u16][данные]. Первая датаграмма — статус New с сетью UDP, портом и адресом назначения и восемью байтами GlobalID (по нему сервер Xray отдаёт тот же UDP-сокет новому потоку того же источника; берётся от адреса источника и случайного ключа процесса), остальные — Keep без адреса. В обратную сторону читаются Keep с данными (адрес отправителя в рамке пропускается), KeepAlive выбрасывается, End и любой другой статус — конец потока. Сверено с Xray-core 26.9.9 и sing-box 1.14.2 (сервер TLS + vision): пять датаграмм по одной, три склеенные одним вызовом и датаграмма 1400 байт возвращаются эхом. - Чего нет. Мультиплекса нескольких адресатов в одном потоке XUDP (адресат потока один, как и у
команды 2) и Mux для TCP (
concurrency): для TCP он экономит только рукопожатия, а их экономит пул запасных связей. - Стоимость рукопожатия спрятана. Первая датаграмма ждёт в буфере ранних данных, пока идёт рукопожатие к узлу, а пул запасных сессий обычно отдаёт готовый поток сразу. Без этого каждое новое соединение QUIC платило бы 100-400 мс.
- Датаграммы до 4096 байт. Сборка частично полученной датаграммы требует буфера на поток, а 64 КБ × 320 потоков — это память, которой у роутера нет. Четырёх килобайт хватает на практике: QUIC и игровой трафик сами держатся внутри MTU, а самый крупный законный случай — ответ DNS с EDNS0, где клиенты объявляют 4096. Датаграмма больше пропускается по её точной длине: потеря синхронизации потока убила бы весь поток, а не одну датаграмму.
- Фрагментация в обе стороны. Датаграмма больше MTU приходит от клиента уже разрезанной его собственным стеком, и туннель собирает её (один слот на рабочий поток, только фрагменты по порядку) прежде чем отдать узлу. В обратную сторону датаграмма больше MTU режется на IP-фрагменты и собирается стеком клиента — без этого крупный ответ DNS молча пропадал бы.
- Срок простоя — те же 120 с, что у TCP, как у conntrack. Меньше означало бы смену исходящего порта на стороне узла для следующей датаграммы, и партнёр QUIC был бы вынужден заново подтверждать путь.
- Потеря есть потеря. Если датаграмму нельзя отдать узлу прямо сейчас (закрылось окно HTTP/2 на
grpc/xhttp), она отбрасывается, а не встаёт в очередь: любой протокол поверх UDP переживает
потерянную датаграмму, но ни один не переживает переупорядочивания.
STEER_TUN_STATS=1показывает их какпотеряно датаграмм.
Задержка установления соединения#
Рукопожатие к узлу (TCP + TLS/Reality + при необходимости HTTP/2) стоит 100-400 мс. На критическом
пути каждого нового соединения оно лежало бы целиком: SYN-ACK клиенту ждал бы, пока готов поток
наверх, а клиент начинает свой TLS только после завершения рукопожатия. У обычной маршрутизации
через nftables такого шага нет.
С пути его убирают три механизма:
- Немедленный
SYN-ACK. Рукопожатие с клиентом завершается за локальный круг, ещё до существования потока к узлу. Адрес назначения VLESS едет в заголовке запроса, а не в момент соединения, поэтому знать про верхнюю сторону пока ничего не нужно. Если поток потом не откроется, клиент получитRST— тот же исход, что при отказе соединения, только позже. - Буфер ранних данных. Раз клиенту ответили сразу, его первые байты (
ClientHello, один-два КБ) приходят, пока рукопожатие к узлу ещё идёт. Они держатся на соединение, до 8 КБ, и отдаются узлу как только поток готов. Сверх этого предела данные просто не подтверждаются, и клиент перешлёт их позже — то же обратное давление, которым уже пользуется путь с закрытым окном. Порядок потока никогда не обменивается на скорость: свежие данные клиента ждут, пока не доставлен накопленный хвост. - Запасные сессии. Небольшой пул заранее установленных сессий к узлу (по умолчанию 4,
STEER_TUN_SPARES=0отключает, максимум 8). Пул пополняется только когда пришёлSYN, поэтому простаивающий роутер не делает никаких фоновых рукопожатий; браузер, открывающий соединения пачками, находит их готовыми. Запасные живут 2,5 с — Xray снимает сессию, не отправившую запрос VLESS в пределах своего таймаута рукопожатия (по умолчанию 4 с), — и просроченные закрываются проходом раз в секунду, чтобы простаивающий пул не держал сокеты и буферы TLS бесконечно.
Пропускной способности и процессора на мегабайт эти механизмы не касаются — только задержки.
Переменные окружения#
Для разбора неполадок, не для повседневной настройки.
| Переменная | Что делает |
|---|---|
STEER_TUN_STATS=1 |
Печатать статистику туннеля: пакеты, датаграммы, потери |
STEER_TUN_SPARES=N |
Размер пула запасных сессий (0 отключает, максимум 8) |
STEER_TUN_THREADS=N |
Число рабочих очередей TUN. По умолчанию одна, и это не экономия: у каждого потока своя таблица соединений, а ядро не обещает отдавать обе половины одного соединения в одну очередь. Разъехавшись, они вешают соединение насмерть |
STEER_TUN_TRACE=1 |
Подробная трассировка пути пакета |
STEER_TUN_NOGSO=1 |
Отключить разгрузку GSO на TUN |
STEER_NOPQ=1 |
Не предлагать гибрид X25519MLKEM768 в ClientHello |
STEER_PQ_TRACE=1 |
Печатать, что сервер выбрал гибрид X25519MLKEM768 |
STEER_VENC_TRACE=1 |
Печатать режим VLESS encryption и то, идёт ли соединение по билету |
STEER_CIPHER=... |
Принудительно выбрать шифр вместо автовыбора по наличию AES |
STEER_FAILOVER_HYST=N |
Сколько подряд здоровых проверок обязано пройти более предпочтительное устройство пула, прежде чем сторож вернёт на него трафик с запасного (по умолчанию 3, 0 — возвращать сразу). Уход с упавшего устройства не задерживается никогда. Держит трафик от мелькания на флаки-узле, который подхватывается пробой на тик и снова падает |
STEER_EXPLAIN_TRACE=1 |
Трассировка в steer explain |
Коды возврата#
steer vless <выход> завершается ненулевым кодом (1) всегда, когда процесс туннеля вообще
возвращается, и печатает в журнал одну строку причины с префиксом steer[warn]. Ноль был бы здесь
неправдой: процесс заканчивается только тогда, когда туннель больше не несёт трафик. procd и
управляющий слой должны трактовать ненулевой код как «туннель упал» и читать строку причины.
Отказы до запуска туннеля отвечают кодом 2 — это отказ вызывающему, а не падение туннеля:
| Код | Строка причины | Что случилось |
|---|---|---|
2 |
steer[warn]: выхода <имя> нет в спеке |
В спеке нет такого выхода |
2 |
steer[warn]: выход <имя> не vless (kind другой) |
Выход есть, но он не kind: vless |
2 |
steer[warn]: <файл> не читается |
Не читается файл подписки |
Отказы при подъёме и работе туннеля отвечают кодом 1:
| Код | Строка причины | Что случилось |
|---|---|---|
1 |
steer[warn]: в подписке нет пригодных узлов ... |
Подписка разобрана, но ни один узел не годится |
1 |
steer[warn]: узла N нет (всего M) |
Задан node, которого в подписке нет |
1 |
steer[warn]: ни один узел подписки не отвечает |
Все узлы проверены, ни один не ответил |
1 |
steer[warn] tunnel: нет /dev/net/tun (...) — не установлен kmod-tun |
Нет /dev/net/tun: не стоит пакет kmod-tun |
1 |
steer[warn] tunnel: устройство <dev> не создалось: ... (отказал TUNSETIFF) |
Устройство TUN не создалось |
1 |
steer[warn] tunnel: <dev> не поднялся ни одной очередью из <N> |
Ни одна рабочая очередь не встала |
1 |
steer[warn] tunnel: <dev> больше не несёт трафик — все очереди (<N>) завершились |
Туннель работал, но все очереди закончились |
Управляющий слой, перезапускающий туннель на ненулевом коде, должен различать случаи: отсутствие
kmod-tun и отказ создания устройства — это проблема инфраструктуры, перезапуск не поможет;
«очереди закончились» — состояние временное, перезапуск может помочь; код 2 означает ошибку в
спеке, и перезапускать бессмысленно до её правки.