splify2
Протоколы

Клиент 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), шифрование VLESS mlkem768x25519plus — см. Постквантовая часть.
  • Трафик: 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:

  1. У сервера есть постоянная пара ключей X25519, публичная половина (pbk) известна клиенту.
  2. Клиент генерирует эфемерную пару и кладёт свою публичную половину в key_share внутри ClientHello.
  3. Из эфемерного приватного ключа клиента и постоянного pbk сервера считается общий секрет. Из него выводится аутентификатор, который прячется в session_id.
  4. Признал аутентификатор — сервер работает как прокси 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 в tls sing-box, skip-cert-verify Clash) подпиской не принимается: узел пропускается с причиной «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 означает ошибку в спеке, и перезапускать бессмысленно до её правки.