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 мс |
Из чего это складывается — четыре разных признака вместо одного:
- Ошибка отправки.
ENETUNREACH,ENETDOWN,EADDRNOTAVAIL,EINVALот сырого сокета — это прямой ответ ядра «пути нет». Прежде такая ошибка была неотличима от прочих и лишь растила счётчик отброшенных, то есть клиент молотил в мёртвый сокет весьDeadMS. - Слежение за netlink (клиент): интерфейсы, адреса IPv4, маршруты IPv4. На любое событие спрашиваем у ядра одно — «с какого адреса ты сейчас уходишь к хабу?» — и на изменение поднимаем соединения немедленно. Сами сообщения не разбираются: решение всё равно принимает ядро, а событие служит поводом спросить.
- Шаг повтора вырос из постоянных пяти секунд в четверть секунды с удвоением до пяти и сбрасывается по двум событиям: соединение прожило дольше десяти секунд или сеть сменилась.
- Порог тишины опущен с 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
по-прежнему сильнее замера: ключ отвечает на «чего я хочу», а замер — на «что быстрее»).
Три вещи, названные прямо#
- По умолчанию ядро НЕ трогается вовсе. Привязка сокета
AF_ALGзаставляет ядро подгрузить фронтендalgif_aeadсамо — а он открывает ядерную криптографию любому процессу и в защищённых сборках выключен намеренно (install algif_aead /bin/false, CVE-2026-31431 «copy.fail»; именно так он выключен на машине, где собирается этот проект). Молча расширять поверхность атаки машины ради процентов скорости нельзя, поэтому «авто» рассматривает ядро только если фронтенд доступен и так. Просьба прямая (--crypto kernel) его пробует — там решил человек. - Движок сверяется с эталоном до того, как через него пойдёт трафик. Проверить
AF_ALGна всех сочетаниях ядра, драйвера и железа невозможно: работать код будет там, где мы его не видели. Поэтому перед выбором ядерный шифр гоняется против своего на пяти длинах (пустая запись, нечётная, полноразмерный пакет, предельная запись) и обязан дать те же байты, расшифровать своё же и ОТВЕРГНУТЬ испорченный тег. Не сошлось — движок не выбирается, и в журнале сказано, чем именно. - Механизм проверен с двух сторон там, где
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, ни одно про протокол:
- Кэш поиска пира вынесен из роутера в отдельное значение. В C у каждого воркера свой роутер, поэтому кэш лежит внутри; здесь роутер один и его читают все горутины, а изменяемое поле в общей структуре — это гонка, которую детектор найдёт под нагрузкой, а не на стенде.
- Замок вокруг номера последовательности. В C один поток на соединение и один
pollна два дескриптора, поэтому защищать нечего. Здесь направления разведены по горутинам с блокирующими чтениями (так этот код заработает и там, где ждать на устройстве придётся иначе — Wintun вообще не дескриптор). Цена: около двадцати наносекунд на пакет без соперничества против примерно микросекунды AEAD. - Проверка прав на файл конфигурации разделена по платформам. На 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 проверяет, что этот путь жив.
Чего протокол не делает#
Списком, чтобы никто не считал это скрытым:
- Не выдерживает активного зондирования. Прибор, приславший настоящий ClientHello, получит
handshake_failureи разрыв. Приём Reality (непризнанного клиента проксировать на настоящий сайт) недоступен принципиально: проксирование требует настоящей точки TCP, а слушающий сокет ядра на том же порту отвечал бы SYN-ACK нашим же пирам. - Отличим от настоящего TLS при целенаправленном анализе — но уже не по одному признаку, а по остаткам: распределение размеров записей повторяет распределение внутренних пакетов, обмен двусторонний и непрерывный, повторных передач не бывает никогда. Что именно осталось и почему — в docs/detect.md.
- Повторов нет — потерянный сегмент это потерянный внутренний пакет.
- Только IPv4 — и снаружи, и внутри. Для клиента за v6-only это отказ. Кадр IPv6 формат различает и пронести умеет, но хаб его отбрасывает со счётчиком: маршрутизации IPv6 у него нет, а разбирал бы он такой кадр как IPv4 — то есть читал бы «адрес источника» из середины настоящего адреса. Пока маршрутизация не сделана, отказ должен быть решением, а не совпадением.
- Ни набивки, ни сокрытия тайминга; контроля перегрузки нет, как и у WireGuard.
- Трафик пир↔пир не проходит через firewall хаба — это плата за разворот в пользовательском пространстве.
- Посредник, переписывающий номера последовательности не постоянным сдвигом (TCP-склейка, прозрачный прокси), ломает протокол целиком.