splify2
xsteer

xsteer под Windows: что решено и почему#

Решения ниже приняты не рассуждением, а замером — стенды в репозитории, числа воспроизводимы.

Почему Windows не получает поддельный TCP «как есть»#

Отправка TCP через сырой сокет запрещена системой с XP SP2 — это закрытая дорога, а не настройка. Приём организовать можно (SIO_RCVALL с правами администратора), но остаётся вторая половина задачи: погасить RST собственного ядра, которое на входящий сегмент без своего сокета отвечает разрывом. На Linux это одно правило nft на приоритете raw. Брандмауэр Windows не умеет матчить флаги TCP, а WFP умеет только из драйвера.

Значит остаются два пути: перехватчик пакетов (WinDivert — свой драйвер, LGPLv3 либо коммерческая лицензия, и трение с антивирусами, о котором пишет сам автор) либо записи по настоящему сокету TCP.

Что показал замер#

tests/loss.sh: одна и та же нагрузка через оба транспорта при потерях 0, 1 и 3 процента. Потери ставятся правилом на входе обеих сторон и только по внешнему порту — внутренний трафик идёт через устройство туннеля и под правило не попадает. Управление перегрузкой BBR (то же, что на машине и в пространствах имён).

транспорт потери вверх, 1 поток вниз, 1 поток вниз, 4 потока
поддельный 0% 637 Мбит/с 641 1462
поддельный 1% 570 705 2041
поддельный 3% 596 713 1389
поток 0% 887 1054 1712
поток 1% 555 677 1221
поток 3% 138 140 513

Читается это так:

  • на чистом канале поток БЫСТРЕЕ на 40–65% — сегментацией занимается ядро, и на пакет приходится меньше системных вызовов;
  • при одном проценте потерь они равны (555 против 570 вверх, 677 против 705 вниз);
  • при трёх процентах поток проваливается в пять раз, а поддельный TCP не замечает потерь вовсе (596 и 713 против своих же 637 и 641 на чистом канале).

Последняя строка — та самая цена TCP внутри TCP, из-за которой протокол и затевался с датаграммной семантикой. Перелом лежит между одним и тремя процентами.

Что показал облик#

XS_STREAM=1 sh tests/xhttp-compare.sh — та же сверка с настоящим xhttp, что в docs/detect.md, но для режима потока:

признак поток поддельный xhttp
опции SYN MSS, SACK_PERM, TS, NOP, WS MSS, NOP, WS, NOP, NOP, SACK_PERM MSS, SACK_PERM, TS, NOP, WS
самый большой сегмент 1448 1460 1448
разных значений окна (хаб) 14, чаще всего 482 23 14, чаще всего 482
PSH 21% 16% 16%
сегментов, начинающих запись 21% 26% 16%
записей на границе сегмента 2% 1% 0%
медиана длины записи 7222 5781 8218
ClientHello 1448 + 311 1460 + 299 1448 + 311

В режиме потока TCP-слой перестаёт быть похожим на настоящий — он и есть настоящий: те же опции (включая метки времени, которые мы не могли себе позволить), тот же MSS, те же значения окна, та же сегментация Hello, байт в байт. Всё, что в поддельном TCP приходилось изображать руками (блуждающее окно, PSH раз в шесть сегментов, голые подтверждения, разрешение SACK), здесь делает ядро — и делает точно.

Остаточное расхождение одно и общее для обоих транспортов: обратное направление у нас состоит из голых подтверждений на 93% против 59% у xhttp, потому что внутренние подтверждения TCP мы собираем в пачки и данных в обратную сторону остаётся мало. Признак слабый, направление маленькое.

Отдельно: в потоке закрывается дырка в защите от зондирования. Проксирование неопознанного к сайту-прикрытию больше не зависит от отсутствия повторных передач — их делает ядро, — а от потока SYN защищают syncookies, а не наша таблица сессий.

Решение#

Windows выходит на режиме потока. Без драйвера сверх Wintun, быстрее на нормальном канале, облик лучше. Поддельный TCP через WinDivert остаётся возможным продолжением — как отдельный режим «для плохой связи», если люди с мобильным интернетом упрутся в те самые три процента.

Порт хаба под поток — отдельный: слушающий сокет ядра отвечал бы SYN-ACK и пирам поддельного TCP, и на один их SYN приходило бы два ответа с разными начальными номерами. Какой режим ставить на :443, решает оператор; поток выглядит настоящим TLS полнее.

Полный туннель не имеет права выключать интернет#

Это отдельный урок, и он дороже остальных, потому что стоил живого стенда.

AllowedIPs = 0.0.0.0/0 означает «весь исходящий трафик машины уходит в туннель». Если туннель при этом ничего не несёт, у человека не «не работает VPN» — у него не работает интернет, и отличить одно от другого снаружи нечем: браузер молчит одинаково. Лечится это только Ctrl-C, причём догадаться до него надо самому.

Именно так и повёл себя хаб на стенде. Замер:

что проверяли что вышло
рукопожатие проходит, ключи сошлись
наши записи на проводе уходят по одной в сегменте, все подтверждены хабом
ответ хаба после рукопожатия 0 байт данных, только чистые ACK
эхо на пробу пути приходит — хаб жив, криптография сходится
ping 10.9.0.1, 8.8.8.8, 1.1.1.1 через туннель 100% потерь
те же адреса без туннеля 24–27 мс

То есть хаб узнаёт пира, принимает и подтверждает каждый сегмент, отвечает на служебные кадры — и не пересылает ни одного IP-пакета. По рукопожатию он неотличим от работающего.

Отсюда два вывода, оба реализованы.

Рукопожатие — не доказательство. Право увести маршрут по умолчанию даёт только кадр ОТ хаба, а удержать его — только настоящие пакеты. Сторож (client.routeGuard) работает в три состояния: ждёт живости → уводит маршрут и даёт испытательный срок 8 секунд → подтверждает или возвращает маршрут обратно с паузой в минуту. Отказ хаба теперь стоит восьми секунд простоя вместо бесконечности, и в журнал уходит прямая формулировка причины:

ВНИМАНИЕ: хаб отвечает на служебные кадры, но за 8 с не вернул НИ ОДНОГО пакета —
похоже, он не пересылает трафик. Маршрут по умолчанию возвращён физическому интерфейсу,
сеть работает как обычно.

Кому простой дороже утечки — --kill-switch: с ним маршрут не возвращается никогда, трафик просто не идёт.

Эхо на пробу нужно и в потоке. Согласовывать MTU в потоке нечем — сегментацией распоряжается ядро, — но проба осталась как единственный признак того, что канал несёт трафик обратно. Три байта раз в две секунды.

За собой надо убирать даже при закрытии окна#

Закрытие консоли на Windows — не сигнал. os/signal переводит здесь в сигналы только Ctrl-C и Ctrl-Break, а крестик на окне приходит как CTRL_CLOSE_EVENT, и по умолчанию процесс исчезает без единого defer. За нами при этом остаются маршрут по умолчанию в мёртвом туннеле, обход /32 на физическом интерфейсе и переписанные серверы имён — то есть человек, закрывший окно, получает машину без сети и без всякого указания, что случилось.

Проверено: обход 109.120.137.190/32 пережил принудительное завершение процесса и остался в таблице маршрутов. Лечится SetConsoleCtrlHandler (cmd/xsteer/console_windows.go); на снятие система даёт около пяти секунд, поэтому уборка ждёт не дольше четырёх и уходит в любом случае. Учёт поставленного ведётся явно (tun.devRoutes), а не «уйдёт вместе с адаптером»: маршрут на ЧУЖОМ интерфейсе закрытием адаптера не снимается.

Что делать по шагам#

  1. Устройство. Wintun: единственный поддерживаемый способ распространения — их подписанная DLL рядом с исполняемым файлом. Биндинг для Go готов и проверен жизнью (golang.zx2c4.com/wintun, используется wireguard-go). Наш интерфейс tun.Device меняться не должен: у Wintun вместо чтения дескриптора кольцевой буфер, и WaitRead там выражается через событие, а не через poll — ровно для этого он и был вынесен в интерфейс.
  2. Настройка интерфейса. Адрес, MTU, маршруты и DNS — через winipcfg по LUID, как это делает сам WireGuard. Наши ip addr/ip route/ip link живут в tun_linux.go и на Windows не вызываются вовсе.
  3. IPv6. Пока туннель поднят, v6 блокируется правилами WFP — как у WireGuard. Иначе при AllowedIPs = 0.0.0.0/0 на машине с работающим v6 трафик уходит мимо туннеля, и человек узнаёт об этом от сайта, который покажет его настоящий адрес. Нести v6 внутри туннеля — отдельная работа на обеих половинах (кадры IPv6 протокол умеет, а маршрутизация хаба пока только про IPv4).
  4. Служба. Одна служба на туннель, запуск от системы: сырой сокет не нужен, но устройство и маршруты требуют прав. Состояние она уже пишет в JSON — тому, кто спросит, читать его.
  5. Интерфейс. По образцу WireGuard for Windows: трей, список туннелей, кнопка «включить», журнал. Обмен с службой — именованный канал; конфигурация та же .conf, что на Linux, поэтому «перенести туннель» означает скопировать файл.
  6. Поставка. Портативный zip (exe + wintun.dll) на первое время, установщик и подпись — потом. Без подписи SmartScreen будет ругаться на каждый запуск; это цена, а не поломка.

Чего я не могу проверить здесь#

Машины с Windows нет. Кросс-сборка (GOOS=windows) и код — здесь; проверка живого туннеля, поведения при сне и пробуждении, реакции антивирусов и SmartScreen — на вашей стороне. Поэтому порядок такой: сначала транспорт и устройство (проверяемо запуском из командной строки с правами администратора), потом служба, потом трей.