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