splify2
xsteer

xsteer#

VPN-протокол с обликом TLS 1.3: клиент для настольных систем и хаб для сервера на Go

Свой VPN-протокол с обликом TLS: обе половины — клиент для настольных систем и хаб для сервера. Вторая реализация того же протокола; первая — на C, в ядре steer (src/proto/xsteer/), который едет на роутеры OpenWrt.

Здесь протокол и развивается. Облик на проводе, стойкость к зондированию и формат кадров меняются сначала тут, а в реализацию на C переносятся отдельной работой. Нынешний формат перенесён целиком; ключ --no-batch возвращает прежний — для хаба предыдущей версии (см. «Формат на проводе и реализация на C»).

tests/matrix.sh гоняет все четыре сочетания половин (хаб на C и на Go против клиента на C и на Go): перекрёстные клетки — единственное, что доказывает формат на проводе, а не согласованность реализации с самой собой.

Что это за протокол#

Клиент несёт IP-пакеты внутри потока, который для наблюдателя выглядит как TLS 1.3 на :443 поверх обычного TCP. Рукопожатие имеет форму ClientHello и ServerHello, каждый пакет данных — форму записи application_data. Транспорт при этом поддельный: заголовок TCP настоящий (поэтому поток проходит там, где UDP блокируют), а семантика датаграммная — один пакет, один сегмент, одна запись, ни повторов, ни окна, ни контроля потока. Так облик достаётся без TCP-поверх-TCP.

Внутри — Noise IK (тот же паттерн, что у WireGuard) со своими ключами и своими пирами. Топология звезда: пиры ходят друг к другу через хаб, который разворачивает их трафик в пользовательском пространстве, без пересылки в ядре.

Накладные расходы — главное число#

Сравнивать надо в пределах ОДНОГО семейства адресов, иначе число выходит бессмысленным: у WireGuard накладные зависят от того, какой адрес у точки, а не только от протокола.

путь байт сверх внутреннего пакета MTU туннеля при канале 1500
WireGuard, точка IPv6 40 IP + 8 UDP + 32 WG = 80 1420
WireGuard, точка IPv4 20 IP + 8 UDP + 32 WG = 60 1440
WireGuard поверх поддельного TCP (IPv4) 20 + 20 + 32 = 72 1428
xsteer (только IPv4) 20 IP + 20 TCP + 5 запись + 16 тег = 61 1439

Восемьдесят — это то число, которое обычно и имеют в виду, и не зря: wg-quick вычитает 80 БЕЗУСЛОВНО, не разбирая, какая у точки версия адреса, поэтому MTU 1420 стоит почти на каждом работающем WireGuard в мире. Шестьдесят достигается только при точке IPv4, и только если считать одни заголовки.

Заголовками при этом дело не кончается: WireGuard добивает открытый текст до кратности шестнадцати перед шифрованием. На MTU это не влияет (1440 само кратно 16), а вот на полосе — да: мелкий пакет платит до пятнадцати лишних байт, которых у нас нет вовсе.

Отсюда честный итог: облик TLS стоит один байт против WireGuard с точкой IPv4 и не стоит ничего против того WireGuard, который стоит у людей. Достаётся это тем, что из кадра выкинуты индекс получателя (его заменяет четвёрка адрес-порт поддельного TCP) и счётчик (его заменяет номер последовательности того же TCP) — ровно те 12 байт, которые оплачивают заголовок записи, — и тем, что набивки до кратности у нас нет.

Что уже работает#

Проверено живьём в пространствах имён (tests/matrix.sh, tests/probe.sh, tests/interop.sh):

  • клиент и хаб, все четыре сочетания с реализацией на C сходятся (перекрёстные — в режиме совместимости);
  • рукопожатие Noise IK внутри ClientHello с обликом Chrome; шифр согласуется порядком наборов, и решает клиент — по своему процессору;
  • связь пир↔хаб и пир↔пир через хаб при выключенном ip_forward: трафик разворачивает сам хаб, без копий и без пересылки в ядре;
  • автоматическое согласование MTU: минимум из пределов сторон, потом проверка самого пути пробами делением отрезка. Под живой сессией сужение пути замечается и предел опускается (в стенде: 1439 → 1334 при пути, режущем на 1400), расширение — возвращается;
  • несколько соединений к хабу, по одному на ядро, с раскладкой потоков по очередям устройства; на хабе — раскладка сессий по воркерам фильтром в ядре;
  • облик на проводе сверен с настоящим xhttp и исправлен по семи признакам — см. docs/detect.md;
  • защита от активного зондирования: неопознанное соединение отдаётся настоящему сайту-прикрытию, и прибор получает подлинный сертификат — тоже см. docs/detect.md;
  • разгрузка сегментации на устройстве (Linux): подряд идущие пакеты одного потока уезжают в ядро одним супер-кадром, а склеенное ядром разбирается на приёме. Замер А/Б — ниже;
  • восстановление без ожидания: смена сети, падение канала и возврат пира с другой точки замечаются сразу, а не по тишине. Сроки измерены стендом tests/roam.sh — тоже ниже;
  • сменный исполнитель шифра: свой код или набор ядра через AF_ALG (а через него — аппаратный ускоритель), выбор по замеру при подъёме;
  • ссылка xs://: та же конфигурация одной строкой — печатается, принимается и превращается в файл.

Скорость#

Замер на одной машине, одним и тем же стендом, оба клиента против одного и того же хаба на C:

четыре потока
хаб на C ← клиент на C 1,12 Гбит/с
хаб на C ← клиент на Go 1,32 Гбит/с
хаб на Go ← клиент на C 1,09 Гбит/с
хаб на Go ← клиент на Go 1,35 Гбит/с

(Замеры разнятся между прогонами на десятки процентов: трафик раскладывается по соединениям хешем потока, и на четырёх потоках раскладка бывает неровной. Числа в таблице — из одного прогона матрицы, а не лучшие из многих.)

Разброс на четырёх потоках не случаен и стоит объяснения: трафик по соединениям раскладывает ядро, симметричным хешем потока по очередям устройства, и на четырёх потоках оно иногда сваливает все четыре в одну очередь — то есть в одно соединение и одно ядро. За шесть прогонов вышло 557, 1148, 1153, 1158, 1178 и 1776 Мбит/с; нижнее число относится к раскладке, а не к туннелю. На шестнадцати потоках раскладка ровная, и стенд проверяет рост именно на них.

Числа без указания машины числами не считаются: здесь это x86-64 с аппаратным AES, veth без потерь, iperf3. На вашем железе будет своё — стенд печатает измеренное, а не обещанное.

Разгрузка сегментации на устройстве: А/Б#

tests/speed.sh поднимает один и тот же туннель дважды подряд — с разгрузкой и с --no-offload, — потому что при разбросе в десятки процентов «стало быстрее» доказывается только двумя прогонами рядом, а не одним числом. Шестнадцать потоков, тот же стенд, три прогона подряд:

направление с разгрузкой без разгрузки
хаб → клиент 2148 / 2187 / 1780 Мбит/с 1011 / 1104 / 983 Мбит/с
клиент → хаб 1923 / 1632 / 1087 Мбит/с 1008 / 1016 / 1405 Мбит/с

На приёме — почти вдвое; на отдаче разброс перекрывает выигрыш, и честно сказать про неё можно только «не хуже». Откуда берётся: одна запись пакета 1400 байт в устройство стоит 3920 нс (это весь приёмный путь ядра — skb, копия, netfilter, маршрутизация), а запись супер-кадра из сорока пяти таких пакетов — 12115 нс, то есть 269 нс на пакет. Разница в 14,6 раза и есть весь смысл затеи; в туннеле она размывается тем, что вторая половина работы (шифрование и сырой сокет) осталась поштучной.

Ядро при этом либо отдаёт супер-кадр целиком локальному сокету, либо режет его на выходе — а на роутере резать умеет сама микросхема (аппаратный TSO есть у mtk_eth_soc и почти любого современного контроллера). Именно это и просят, когда говорят «hardware offloading»: пакеты не собираются по одному ни у нас, ни в ядре.

Восстановление: сроки#

tests/roam.sh поднимает туннель в пространствах имён и портит сеть тремя способами, замеряя, через сколько туннель снова несёт трафик. Раньше единственным признаком беды была тишина, и все три случая стоили десятков секунд.

случай было стало
канал упал на 5 с и вернулся до 50 с (DeadMS 45 с плюс шаг повтора 5 с) 255 мс
трафик к хабу ушёл через другой интерфейс до 50 с 1,1 с
пир вернулся, заняв меньше слотов до 3 мин (IdleMS 180 с) 745 мс

Из чего это складывается — четыре разных признака вместо одного:

  1. Ошибка отправки. ENETUNREACH, ENETDOWN, EADDRNOTAVAIL, EINVAL от сырого сокета — это прямой ответ ядра «пути нет». Прежде такая ошибка была неотличима от прочих и лишь растила счётчик отброшенных, то есть клиент молотил в мёртвый сокет весь DeadMS.
  2. Слежение за netlink (клиент): интерфейсы, адреса IPv4, маршруты IPv4. На любое событие спрашиваем у ядра одно — «с какого адреса ты сейчас уходишь к хабу?» — и на изменение поднимаем соединения немедленно. Сами сообщения не разбираются: решение всё равно принимает ядро, а событие служит поводом спросить.
  3. Шаг повтора вырос из постоянных пяти секунд в четверть секунды с удвоением до пяти и сбрасывается по двум событиям: соединение прожило дольше десяти секунд или сеть сменилась.
  4. Порог тишины опущен с 45 с до 8 с — и это стало возможно потому, что причина прежнего порога была не в пути, а в нас. Пятнадцать секунд однажды сработали ложно на живом роутере: посторонние процессы заняли оба ядра, наша горутина получила 4% времени, входящие сегменты пролежали в очереди дольше порога. Теперь Tick замечает, что его самого не вызывали, и это время в тишину не засчитывается — смешения «путь молчит» и «мы не работали» больше нет. Восемь секунд при активной отправке — это двести пропущенных подтверждений подряд: хаб подтверждает принятое голым ACK через 40 мс.

На стороне хаба к этому добавлена проба переезда. Пир, поднявшийся заново, приходит с новых портов источника, поэтому свои слоты он вытесняет сам — а те, которые он больше не занимает, оставались указывать в точку, которой нет. Получателя хаб выбирает хешем потока среди живых слотов, значит мёртвый слот — это не задержка, а потеря доли потоков целиком. Теперь на каждое рукопожатие остальные соединения того же пира получают три пустые записи через 150 мс и обязаны ответить; промолчавший слот снимается за полсекунды вместо трёх минут.

Заодно уборка по простою стала считаться по ПОДТВЕРЖДЁННОМУ трафику, а не по любому принятому сегменту: на публичный порт хаба сыплется сканерный шум, и пока простой считался по нему, сессия, в которую пир давно не пишет, могла жить сколько угодно — достаточно, чтобы кто-то посторонний бил в её четвёрку.

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

Кто считает шифр: замер вместо признаков процессора#

На слабой машине криптография — это не «горячий путь наряду с прочим», а почти весь пакет. Замер на живом роутере (mt7621, mipsel 24Kc, xsteer crypto, пакет 1440 байт):

работа на пакет на поток
ChaCha20-Poly1305 (свой код) 258 545 нс 44 Мбит/с
AES-128-GCM (свой код) 403 979 нс 28 Мбит/с
сумма TCP (по 8 байт) 7 182 нс 1604 Мбит/с

Криптография там дороже суммы в тридцать шесть раз, то есть составляет 97% стоимости пакета. Ни разгрузка устройства, ни пакетная отправка, ни экономия копий этого не сдвигают — сдвинуть может только другой исполнитель шифра.

Модуль WireGuard (и amneziawg) свою криптографию наружу не отдаёт: интерфейса для этого у него нет и не предполагалось. Но он тянет за собой kmod-crypto-lib-chacha20poly1305, а тот регистрирует шифр в общем наборе ядра — и вот до набора ядра дотянуться можно, через AF_ALG. Там же живёт аппаратный ускоритель, если он есть: на том же mt7621 драйвер crypto_hw_eip93 регистрирует ctr(aes-eip93) и cbc(aes-eip93) с приоритетом 1500, то есть AES считает микросхема.

Поэтому исполнитель шифра стал сменным, а выбор — измеряемым:

xsteer crypto            # что на этой машине быстрее (ядро не трогается)
xsteer crypto --probe    # то же, но опросить ядро по-настоящему
xsteer up ... --crypto auto     # умолчание: решает замер при подъёме
xsteer up ... --crypto kernel   # только ядро; не берёт — отказ, а не тихий возврат
xsteer up ... --crypto go       # только свой код

На проводе не меняется ни байт. gcm(aes) и rfc7539(chacha20,poly1305) в наборе ядра — те же конструкции, что crypto/cipher и x/crypto здесь: те же ключи, те же nonce, те же теги. Поэтому исполнителя выбирает каждая сторона себе сама, и вторая ничего не замечает.

Заодно замер отвечает на вопрос, который раньше решался флагом процессора. Правило «есть AES-NI — берём AES-GCM» верно, пока исполнитель один; как только их стало два, оно ломается в обе стороны: у процессора без AES-NI может быть аппаратный AES в ядре, а на x86 с AES-NI ядерный путь проигрывает своему из-за двух системных вызовов на пакет. Теперь шифр выбирается по измеренному (--chacha по-прежнему сильнее замера: ключ отвечает на «чего я хочу», а замер — на «что быстрее»).

Три вещи, названные прямо#

  1. По умолчанию ядро НЕ трогается вовсе. Привязка сокета AF_ALG заставляет ядро подгрузить фронтенд algif_aead само — а он открывает ядерную криптографию любому процессу и в защищённых сборках выключен намеренно (install algif_aead /bin/false, CVE-2026-31431 «copy.fail»; именно так он выключен на машине, где собирается этот проект). Молча расширять поверхность атаки машины ради процентов скорости нельзя, поэтому «авто» рассматривает ядро только если фронтенд доступен и так. Просьба прямая (--crypto kernel) его пробует — там решил человек.
  2. Движок сверяется с эталоном до того, как через него пойдёт трафик. Проверить AF_ALG на всех сочетаниях ядра, драйвера и железа невозможно: работать код будет там, где мы его не видели. Поэтому перед выбором ядерный шифр гоняется против своего на пяти длинах (пустая запись, нечётная, полноразмерный пакет, предельная запись) и обязан дать те же байты, расшифровать своё же и ОТВЕРГНУТЬ испорченный тег. Не сошлось — движок не выбирается, и в журнале сказано, чем именно.
  3. Механизм проверен с двух сторон там, где algif_aead недоступен. Раскладка управляющих сообщений — разбором штатным ParseSocketControlMessage; живой вызов с теми же сообщениями — на algif_skcipher против эталонного AES-CTR. На машине сборки этого проекта иного способа нет: фронтенд там запрещён из-за CVE.

Числа с живого роутера#

kmod-crypto-user (фронтенд AF_ALG) и kmod-crypto-lib-chacha20poly1305 в OpenWrt — отдельные пакеты. С ними на mt7621 (xsteer crypto --probe, пакет 1440 байт):

исполнитель на пакет на поток
ChaCha20-Poly1305, ядро rfc7539(chacha20-mips,poly1305-mips) 97 624 нс 118 Мбит/с
AES-128-GCM, ядро gcm_base(ctr(aes-eip93),ghash-generic) 257 697 нс 44 Мбит/с
ChaCha20-Poly1305, свой код 258 836 нс 44 Мбит/с
AES-128-GCM, свой код 526 351 нс 21 Мбит/с

Ядерная ChaCha быстрее своей в 2,65 раза — и это ровно тот код, который приносит с собой wireguard (и amneziawg): в имени драйвера видно chacha20-mips и poly1305-mips, то есть ассемблер под эту архитектуру. Аппаратный AES при этом не помогает: микросхема считает ctr(aes), а ghash остаётся программным и съедает выигрыш целиком — 44 Мбит/с, ровно как у своей ChaCha.

А в туннеле разницы нет, и это тоже число. А/Б настоящего туннеля на том же роутере (обе половины в пространствах имён, два потока): 16,1 против 15,5 Мбит/с на отдаче и 17,8 против 17,8 на приёме. Объяснение простое: при 17 Мбит/с это 1,5 тысячи пакетов в секунду, то есть ядерная ChaCha занимает 29% одного ядра — предел лежит не в шифре, а во всём остальном (два процесса iperf3, veth, две половины туннеля и рантайм Go на 880 МГц MIPS). Значит выигрыш достанется тому, у кого шифр действительно упирается: реализации на C, которая несравнимо легче, или роутеру побыстрее. Обещать его для этой реализации на этом железе было бы неправдой.

Стоит запомнить и первую попытку этого замера: обе половины поднимались в ОДНОМ пространстве имён, и iperf3 до адреса хаба показал 762 Мбит/с — на машине, где один шифр стоит 97 мкс на пакет. Число невозможное, и причина в том, что адрес хаба на той же машине локальный: трафик до него не входил в туннель вовсе.

Что стоит дорого на пакет#

работа 1460 байт
контрольная сумма TCP, проход по 2 байта (было) 447 нс
контрольная сумма TCP, проход по 8 байт (стало) 98 нс
AES-128-GCM 362 нс
ChaCha20-Poly1305 688 нс
отправка сегмента сырым сокетом, по одному (send) 3325 нс
то же пачкой по восемь (sendmmsg) 2641 нс
то же пачкой по 64 2543 нс

Первая строка стоит внимания: самой дорогой работой на горячем пути была не криптография, а контрольная сумма — она обходилась дороже AES-GCM на том же пакете. Проход по восемь байт с одной перестановкой на результат (пакет csum) снял с неё четыре пятых.

Последние строки стоит прочесть внимательно: дорог не вход в ядро, а сам путь вывода — 2,5 мкс на пакет сам по себе. Поэтому пакетная отправка (sendmmsg) снимает около четверти, а не разы: запись, разрезанная на сегменты, уезжает одним вызовом вместо шести-восьми, и заодно исчезает копия продолжения — тело каждого сегмента отдаётся ядру срезом прямо из записи, а свой двадцатибайтовый заголовок идёт вторым куском.

Проверяется это проводом, а не рассуждением: одна и та же запись отправляется двумя путями, пакеты ловятся с провода сокетом AF_PACKET и сравниваются побайтово. Проверять есть что — в пакетной отправке две вещи, которые нельзя обосновать: раскладка struct mmsghdr (в x/sys её нет, поэтому она описана у нас) и сумма TCP, сосчитанная по двум частям вместо непрерывного прохода. Ошибка в любой даёт не отказ, а поток, который conntrack по дороге считает недействительным, — то есть тихо съеденный туннель. Стенд проверен на ложность: порча суммы роняет его на «сегмент ушёл другими байтами», порча раскладки — на «ядро приняло 1 сегмент из 4». И прогнан он на обеих ширинах слова — на x86-64 и на том же роутере mipsel.

Шифрование идёт на месте, заголовки пишутся перед нагрузкой в том же буфере, копий нагрузки нет ни в одну сторону.

Чего пока нет#

Названо прямо, потому что заглушка, молча возвращающая «всё хорошо», выглядит как реализованная возможность:

  • Windows. Отправка сырого TCP запрещена системой начиная с XP SP2 — не настройкой, а принципиально. Нужен перехватчик (WinDivert или Npcap) плюс Wintun для устройства. Отказ сейчас так и говорит.
  • macOS. Сырой сокет умеет отправлять TCP, но BSD не доставляет TCP на сырые сокеты вовсе: приём придётся делать через /dev/bpf. Устройство — utun.
  • Настоящая точка TCP для зондировщика — см. ниже.
  • Настоящая точка TCP для зондировщика (с окном и повторами): без неё режим proxy не выдержит пути с потерями. Цену надо мерить.
  • Смены ключей «поднять новое, потом отпустить старое»: при достижении предела (гигабайт или двадцать минут) соединение поднимается заново, и туннель молчит один круг обмена.

Сборка и запуск#

go build -o build/xsteer ./cmd/xsteer

# ключи: как wg genkey / wg pubkey
./build/xsteer genkey > private.key
./build/xsteer pubkey < private.key

# туннель (нужен root или CAP_NET_RAW+CAP_NET_ADMIN)
sudo ./build/xsteer up /etc/xsteer/hub.conf --routes --state /run/xsteer.json
./build/xsteer show /run/xsteer.json

Конфигурация — в стиле WireGuard, тот же файл, что читает реализация на C:

[Interface]
PrivateKey = <44 символа base64>
Address    = 10.77.0.2/24
SNI        = www.microsoft.com

[Peer]
PublicKey  = <публичный ключ хаба>
AllowedIPs = 10.77.0.0/24
Endpoint   = 203.0.113.7:443
PersistentKeepalive = 25

Ссылка xs:// — та же конфигурация одной строкой#

Файл хорош там, где его пишут руками, и плох там, где его передают: в сообщении, в QR-коде, в поле ввода на странице. Ссылка решает ровно эту задачу — и ничего кроме: формат ссылки и формат файла описывают одно и то же, разбираются в одну структуру и проверяются одними правилами. Задать ссылкой то, чего нельзя задать файлом, нельзя — иначе получилось бы два разных понятия «настроено».

xsteer link /etc/xsteer/home.conf --name дом     # напечатать ссылку
xsteer conf  - < home.link                        # обратно в файл
xsteer up    - < home.link                        # поднять прямо по ссылке
xsteer check - < home.link                        # проверить, ничего не поднимая
qrencode -t ansiutf8 < home.link                  # отдать телефоном

Вид:

xs://<приватный ключ пира>@<хост>:<порт>?pk=<публичный ключ хаба>&ip=10.77.0.2/24#дом
параметр что задаёт
pk публичный ключ хаба (обязателен)
ip адрес внутри туннеля с длиной префикса (обязателен)
allowed AllowedIPs через запятую; по умолчанию — сеть из ip, а не 0.0.0.0/0
sni имя, которым прикрывается рукопожатие
mtu предел туннеля; по умолчанию выводится из MTU канала
ka PersistentKeepalive в секундах; ka=0 выключает, отсутствие даёт умолчание 25
dns серверы имён через запятую

Умолчание allowed выбрано в безопасную сторону намеренно: полный туннель — воля человека, а не то, что случается от краткости ссылки.

В ссылке лежит приватный ключ — это её суть, а не оплошность: ссылка и есть выданный доступ целиком. Отсюда «-» во всех командах: аргументы команды видны в списке процессов всякому, кто есть на машине, и остаются в истории оболочки, поэтому на общей машине ссылка передаётся стандартным вводом, а не аргументом. Ключи в ссылке записаны base64url без набивки (обычный base64 содержит + и /, которым нужна процентная запись); на разборе принимаются оба алфавита — ключ вставляют и прямо из wg-конфигурации.

Разбор строгий, как у файла: неизвестный параметр — отказ с подсказкой, повтор параметра — тоже отказ. xs-install.sh выдаёт ссылку рядом с файлом при добавлении пира, а xs-quick берёт /etc/xsteer/<имя>.link, если файла .conf нет.

Разбор строгий: ключи wg, поведение которых мы не реализуем (DNS, Table, FwMark, PostUp и прочие), отвергаются с объяснением, а не принимаются молча — иначе Table = off, скопированный из рабочего wg-quick, означал бы «настроено», не настроив ничего. Опечатка в имени ключа получает подсказку.

Устройство исходников#

пакет что внутри откуда портирован
wire запись 17 03 03, nonce от относительного смещения, окно приёма, пределы соединения, согласование MTU src/proto/xsteer/xswire.{c,h}
conf конфигурация в стиле wg, строгий разбор, секреты отделены типом src/proto/xsteer/xsconf.{c,h}
route AllowedIPs самым длинным префиксом, право на адрес источника, TTL, подрезка MSS, хеш потока src/proto/xsteer/xsroute.{c,h}
chello ClientHello с обликом Chrome: сборка и разбор src/proto/tls/reality.c, src/proto/tls/chello.c
noise Noise IK внутри Hello, транспортные ключи, AEAD src/proto/xsteer/xshake.c, src/proto/tls/tls13.c
link поддельное TCP-соединение, сырой сокет, фильтр cBPF, правило против RST своего ядра src/proto/xsteer/xsconn.{c,h}, src/proto/obfs/obfs.c
tun устройство (Linux; macOS и Windows — впереди) src/tunnel/tun.c
client цикл пира: рукопожатие, данные, пачки, пробы пути, keepalive, пределы src/proto/xsteer/xsclient.c
hub центр звезды: сессии, раскладка по воркерам, пир↔пир, дорожка неопознанных src/proto/xsteer/xshub.c

Три отличия от реализации на C — все про Go, ни одно про протокол:

  1. Кэш поиска пира вынесен из роутера в отдельное значение. В C у каждого воркера свой роутер, поэтому кэш лежит внутри; здесь роутер один и его читают все горутины, а изменяемое поле в общей структуре — это гонка, которую детектор найдёт под нагрузкой, а не на стенде.
  2. Замок вокруг номера последовательности. В C один поток на соединение и один poll на два дескриптора, поэтому защищать нечего. Здесь направления разведены по горутинам с блокирующими чтениями (так этот код заработает и там, где ждать на устройстве придётся иначе — Wintun вообще не дескриптор). Цена: около двадцати наносекунд на пакет без соперничества против примерно микросекунды AEAD.
  3. Проверка прав на файл конфигурации разделена по платформам. На Unix доступ «остальным» — отказ с готовым chmod. На Windows её нет: os.FileInfo там синтезирует режим из атрибута «только чтение», и проверка либо всегда молчала бы, либо всегда жаловалась. Настоящая требует разбора DACL — так и написано в коде.

Обвязка#

xsteer умеет всё сам — адрес, MTU, маршруты, согласование пути, — поэтому обвязка нужна только для того, чего он не делает намеренно: ключей-крючков (PostUp и прочие разбор отвергает: клиент не исполняет команды из файла) и слежения за процессом. Подробно — в docs/deploy.md.

# Linux-клиент: как wg-quick, с крючками и Table = off
sudo install -m 755 contrib/xs-quick /usr/local/sbin/xs-quick
sudo xs-quick add home < xsteer-home.link   # ссылка или конфигурация от хаба
sudo xs-quick up home                       # /etc/xsteer/home.conf или .link
sudo systemctl enable --now xs-quick@home

# хаб на сервере: вопросы, юнит, masquerade, меню выдачи пиров и QR для телефона
curl -fsSLO https://raw.githubusercontent.com/splify2/xsteer/main/server/xs-install.sh
sudo bash xs-install.sh

# проверить конфигурацию, ничего не поднимая
xsteer check /etc/xsteer/hub.conf

Проверки#

go test ./...              # всё, что проверяется без сети и без root
go vet ./...
sudo sh tests/matrix.sh    # все четыре сочетания половин C и Go
sudo sh tests/probe.sh     # что видит прибор активного зондирования (нужен openssl)
sudo XRAY_BIN=/путь/к/xray sh tests/xhttp-compare.sh   # форма трафика против настоящего xhttp
sudo sh tests/interop.sh   # живая совместимость с хабом на C (нужны netns, tun, ../steer/build/steer-hub)
sudo sh tests/quick.sh     # обвязка: xs-quick поднимает и снимает настоящий туннель

# тот же стенд под детектором гонок: у клиента три горутины на соединение, и проверять их
# согласованность рассуждением нельзя. Скорость при этом падает примерно вдвое — это накладные
# расходы детектора, а не туннеля.
go build -race -o build/xsteer-race ./cmd/xsteer
sudo XSTEER_BIN=./build/xsteer-race sh tests/interop.sh

Тесты повторяют случаи стендов реализации на C (tests/xswirematch.c, xsconfmatch.c, xsroutematch.c, chellomatch.c, xsloop.c) — не ради симметрии, а потому что две реализации одного протокола обязаны отвечать одинаково, и «обязаны» с «отвечают» надо сверять.

Формат на проводе и реализация на C#

Формат развивается здесь, а в реализацию на C переносится отдельной работой. Сейчас перенос сделан: пачки кадров, разрезание записей между сегментами, ClientHello браузерного размера и исправленный профиль TCP (разрешение SACK в SYN, блуждающее окно, PSH как у настоящего стека, голые подтверждения, keepalive с разбросом) есть в обеих реализациях, и tests/matrix.sh гоняет все четыре сочетания половин на новом формате.

Ключ --no-batch (есть и у клиента, и у хаба) возвращает прежний формат — он нужен для разговора с хабом предыдущей версии. XSM_COMPAT=1 sh tests/matrix.sh проверяет, что этот путь жив.

Чего протокол не делает#

Списком, чтобы никто не считал это скрытым:

  1. Не выдерживает активного зондирования. Прибор, приславший настоящий ClientHello, получит handshake_failure и разрыв. Приём Reality (непризнанного клиента проксировать на настоящий сайт) недоступен принципиально: проксирование требует настоящей точки TCP, а слушающий сокет ядра на том же порту отвечал бы SYN-ACK нашим же пирам.
  2. Отличим от настоящего TLS при целенаправленном анализе — но уже не по одному признаку, а по остаткам: распределение размеров записей повторяет распределение внутренних пакетов, обмен двусторонний и непрерывный, повторных передач не бывает никогда. Что именно осталось и почему — в docs/detect.md.
  3. Повторов нет — потерянный сегмент это потерянный внутренний пакет.
  4. Только IPv4 — и снаружи, и внутри. Для клиента за v6-only это отказ. Кадр IPv6 формат различает и пронести умеет, но хаб его отбрасывает со счётчиком: маршрутизации IPv6 у него нет, а разбирал бы он такой кадр как IPv4 — то есть читал бы «адрес источника» из середины настоящего адреса. Пока маршрутизация не сделана, отказ должен быть решением, а не совпадением.
  5. Ни набивки, ни сокрытия тайминга; контроля перегрузки нет, как и у WireGuard.
  6. Трафик пир↔пир не проходит через firewall хаба — это плата за разворот в пользовательском пространстве.
  7. Посредник, переписывающий номера последовательности не постоянным сдвигом (TCP-склейка, прозрачный прокси), ломает протокол целиком.