diff --git a/.gitignore b/.gitignore index 55cf68a..832a5f4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist/ tools/core*/ tools/core.zip tools/sing-box-*-source.zip +tools/xray-unpacked/ +tools/Xray-*.zip +tools/Xray-*.zip.dgst research/*.apk research/phone*.xml research/phone*.png @@ -14,6 +17,7 @@ src/QVPN.App/*smoke.json research/*.png research/phone-package.txt research/*verification.json +research/*verification-*.json research/service-regression.json research/recovery-socks.json research/disconnect-write-failure.json diff --git a/Directory.Build.props b/Directory.Build.props index 323d624..ba55ff5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1 +1 @@ -net10.0-windowsenableenablelatest1.0.1truetrue +net10.0-windowsenableenablelatest1.0.5truetrue diff --git a/README.md b/README.md index 6414219..c48535d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # QVPN для Windows -Собственный клиент для **BlancVPN**. Импортирует HTTPS-ссылку `*.withblancvpn.online/s/…`, получает локации VLESS/Reality и подключает весь Windows через виртуальный сетевой адаптер. Другие VPN-провайдеры и произвольные конфигурации не принимаются. Личный ключ в исходники и установщик не включён. +Собственный клиент для **BlancVPN**. Подключение VLESS/Reality выполняет Xray 26.9.9 — официальный бинарный файл побайтово совпадает с ядром установленного Happ 4.5.4, проверенного 22 октября 2026 года. Системный туннель, DNS и маршрутизацию Windows обеспечивает sing-box, передавая VPN-трафик в локальный Xray. Импортирует HTTPS-ссылку `*.withblancvpn.online/s/…`. Другие VPN-провайдеры и произвольные конфигурации не принимаются. Личный ключ в исходники и установщик не включён. ## Установка и работа -1. Запустите `QVPN-Setup-1.0.1.exe`. Windows попросит права администратора один раз для установки VPN-службы. +1. Запустите `QVPN-Setup-1.0.5.exe`. Windows попросит права администратора один раз для установки VPN-службы. 2. Откройте ярлык QVPN. Вставьте ссылку BlancVPN в поле ключа и нажмите «Добавить ключ». 3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить». @@ -19,9 +19,15 @@ - Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна. - Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа. - Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя. -- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд. -- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации. -- Возврат из сна и появление сети восстанавливают VPN. Обновление подписки раз в 6 часов сохраняет рабочее соединение, если его параметры не изменились. +- Желание оставаться подключённым и последняя рабочая локация сохраняются на диск атомарно. Сбой локального процесса повторяет подключение к рабочему серверу. Недоступные локации переключаются без минутных пауз; задержка от 2 до 60 секунд применяется при повторных локальных сбоях или после полного неудачного круга серверов. +- HTTPS-проверки выполняются новыми соединениями параллельно с общим пределом 6 секунд. В системном режиме нужны поднятый адаптер QVPN и ответ 204 по двум путям: через локальный VPN-прокси и через обычный DNS/HTTPS Windows. Рабочий прокси больше не скрывает отказ системного туннеля. Повторный отказ только системного пути восстанавливает ту же локацию; отказ прокси включает смену сервера. Разовая ошибка не разрывает рабочий туннель; три подряд приводят к восстановлению, при первом подключении недоступный сервер пропускается после двух проверок. Проверки не блокируют команды интерфейса. +- Возврат из сна, смена физической сети и её появление сбрасывают старые задержки восстановления. Последняя рабочая локация предпочтительнее случайного нового сервера. TCP-задержки измеряются прямо через физические адаптеры, включая время активного VPN. Xray привязывается к адаптеру, который достиг выбранного сервера; если проба не прошла, единственный физический адаптер всё равно допускает настоящую VPN-попытку. +- Обновление подписки раз в 6 часов выполняется в фоне и сохраняет рабочее соединение при неизменных параметрах подключения; переименование локации не перезапускает туннель. +- Новое подключение проверяет текущие параметры подписки. Старый кэш при запуске службы и повторные ошибки связи также запускают обновление; автоматические запросы ограничены одной попыткой в минуту. Проверка перед запуском туннеля занимает максимум 5 секунд, при недоступности подписки используются сохранённые локации. +- Если провайдер обновил параметры сервера и его ID, выбранная локация сохраняется по однозначному соответствию. Смена ключа или транспорта переподключает эту же локацию вместо сброса выбора на первый сервер. +- Обновление неизменившейся подписки сохраняет список отказавших соединений и позволяет попробовать свежие локации. Изменение учётных параметров, flow или fingerprint разрешает повторную попытку. Разные варианты flow/fingerprint одного сервера больше не удаляются как дубли; поддерживается также явно указанный в подписке `xtls-rprx-vision-udp443`. +- В версии 1.0.5 служба проверяет через Windows IP Helper, что контрольные публичные IPv4/IPv6-маршруты действительно принадлежат адаптеру QVPN. Поднятый адаптер и успешный HTTPS через обычную сеть больше не скрывают обход VPN. Потеря маршрута немедленно снимает статус «подключён»; повторный отказ восстанавливает туннель на том же сервере. Отсутствие доступного IPv6-маршрута допускается. Результат доступен в диагностике как `SystemRoutesVerified`. +- Таймаут загрузки подписки охватывает заголовки и всё тело ответа: 12 секунд на попытку, один повтор с паузой в секунду. Зависшее тело больше не занимает фоновое обновление бессрочно; отмена подключения и пятисекундный предел стартового обновления действуют раньше. - Windows автоматически перезапускает аварийно завершившуюся службу. Job Object закрывает принадлежащие ей VPN-процессы при её завершении. - Журнал ограничен по размеру и содержит события подключения, без ключа, UUID и истории посещений. @@ -29,14 +35,16 @@ ## Разработка и проверка -Требуется .NET SDK 10. Подготовка зависимостей в новой копии исходников: `powershell -File scripts\prepare-core.ps1`; сборка: `powershell -File scripts\build.ps1`. Приложение и служба публикуются для Windows x64 с включённым .NET runtime; установка .NET на целевом компьютере не нужна. +Требуется .NET SDK 10 и PowerShell 7. Подготовка зависимостей в новой копии исходников: `pwsh -File scripts\prepare-core.ps1` и `pwsh -File scripts\prepare-xray.ps1`; сборка: `pwsh -File scripts\build.ps1`. Приложение и служба публикуются для Windows x64 с включённым .NET runtime; установка .NET на целевом компьютере не нужна. Тесты: `dotnet run --project tests\QVPN.Tests -c Release`. +Проверка установленного активного VPN с правами администратора: `pwsh -File scripts\verify-stability-installed.ps1 -InstallUpdate`. Сохраняет резервную копию зашифрованных настроек, обновляет программу, проверяет реальный TUN/HTTPS, TCP-замеры и обновление подписки без разрыва, сбой ядра и службы с возвратом к прежнему серверу, затем наблюдает соединение 5 минут. Временный разрыв при испытании сбоев ожидаем. Публичный отчёт содержит только результаты, время и счётчики. + Диагностика без изменения системных маршрутов: ```powershell -dotnet run --project src\QVPN.Service -c Release -- --console --socks-only --data C:\Tools\QVPN-private\dev-state --core C:\Repos\QVPN\tools\core-unpacked\sing-box-1.14.2-windows-amd64\sing-box.exe +dotnet run --project src\QVPN.Service -c Release -- --console --socks-only --data C:\Tools\QVPN-private\dev-state --core C:\Repos\QVPN\tools\core-unpacked\sing-box-1.14.2-windows-amd64\sing-box.exe --xray C:\Repos\QVPN\tools\xray-unpacked\xray.exe ``` Тестовый режим доступен только при ручном консольном запуске; обычная служба всегда создаёт системный TUN. Для остановки тестового режима используйте Ctrl+C. Не запускайте тестовую и установленную службу одновременно. @@ -45,6 +53,8 @@ dotnet run --project src\QVPN.Service -c Release -- --console --socks-only --dat ## Открытые компоненты +Xray 26.9.9: [официальный релиз](https://github.com/XTLS/Xray-core/releases/tag/v26.9.9). Эта версия выбрана для совпадения с проверенным Happ; upstream отмечает её как предварительную. Поставляется без изменений вместе с MPL 2.0 и архивом исходников. Происхождение и SHA-256 закреплены в `research/xray-release.json` и `scripts/prepare-xray.ps1`. Xray привязан к физическому сетевому адаптеру, чтобы его подключения не попадали обратно в системный туннель. + Неизменённый sing-box 1.14.2 используется как отдельный процесс. Документация: https://sing-box.sagernet.org/. Исходники: https://github.com/SagerNet/sing-box/tree/v1.14.2. Оригинальные уведомления и GPLv3 поставляются вместе с программой. Правила маршрутизации: SagerNet/sing-geoip и SagerNet/sing-geosite; адреса и SHA-256 записаны в `research/rules-sources.json`. QVPN — личное независимое приложение, не официальный продукт BlancVPN. Брендовые изображения, APK и закрытый код мобильного приложения в поставку не включены. diff --git a/research/analysis.md b/research/analysis.md index 3c5516a..564fb78 100644 --- a/research/analysis.md +++ b/research/analysis.md @@ -81,3 +81,29 @@ Upstream `LICENSE` для точного тега разрешает sing-box п Версия 1.0.1 убирает отложенный запуск службы. При отсутствии ответа интерфейс проверяет состояние в диспетчере служб и автоматически запускает остановленную службу. Установщик добавляет владельцу права START и QUERY_STATUS с сохранением остальных ACL; изменения конфигурации службы и остановка этим разрешением не предоставляются. Сообщение об установке появляется только при подтверждённом отсутствии регистрации службы (код Windows 1060). Ожидание запуска, отключённая служба и отказ доступа показываются отдельно. Обновление сначала закрывает старое окно, затем останавливает службу, чтобы интерфейс не запускал её во время замены файлов. Сборка 1.0.1 и 16 групп тестов прошли. `scripts/verify-startup-installed.ps1 -InstallUpdate` обновляет программу с сохранением подписки, проверяет отсутствие отложенного старта, останавливает службу и запускает обычное окно без повышенных прав. Успешный результат требует повторного подключения с прежним числом локаций и HTTPS 204 через системный TUN. JSON и отрисовки проверки исключены из Git. Этот сценарий проверяет восстановление после остановки службы; реальную перезагрузку компьютера он не выполняет. + +## Повторное обследование BlancVPN и восстановления QVPN, 6 октября 2026 + +Проверка телефона выполнена через read-only ADB и локальные APK установленного приложения. VPN на телефоне не отключался, интерфейс не управлялся, журналы с сетевыми адресами и конфигурации пользователя не читались. Подтверждены `com.blancvpn.app` версии 1.8.7, код 189, активная foreground-служба `XrayVpnService` в отдельном процессе `:xrayService`, PID 15201. Снимок службы показывает `startRequested=true`, `stopIfKilled=false`, `startCommandResult=1`; последнее соответствует `START_STICKY`. Снимок не измеряет длительность непрерывной передачи данных и сам по себе не доказывает отсутствие прежних разрывов. + +В нативной библиотеке APK найдена Go build metadata с зависимостью `github.com/xtls/xray-core` версии `v1.260327.1-0.20260627131803-45cf2898ab12`. Также присутствует отдельная `libhev-socks5-tunnel.so`. Это подтверждает использование Xray в телефонном компоненте, тогда как QVPN на Windows использует sing-box. Разные ядра и способы подключения виртуального сетевого интерфейса могут давать различия поведения, но обследование не устанавливает, что причиной жалобы является sing-box или что замена ядра сама по себе решит проблему. + +Точечная проверка структуры и инструкций VPN-компонента APK подтверждает восстановление сохранённых параметров туннеля при перезапуске службы без новых Intent extras. Восстановление зависит от сохранённого состояния: приложение отдельно обрабатывает отсутствие конфигурации и состояние, при котором восстанавливать туннель не нужно. Повторная команда с уже принятым `connection_id` игнорируется. Обнаружен зарегистрированный Android network callback: при смене сети он ждёт подтверждённой доступности новой сети (`NET_CAPABILITY_VALIDATED`), затем вызывает обработчик восстановления. Обработчик пропускает восстановление после отзыва VPN-разрешения и в неподключённом состоянии, обновляет underlying network и передаёт восстановление текущему VPN-компоненту. Это проверенные ветви поведения; они не являются измерением фактического времени переподключения на телефоне. + +В Flutter AOT найдены имена собственных компонентов проверки соединения, сохранения последнего успешного подключения, резервного выбора и сетевой проверки, а также параметры ограничения времени попытки и количества health-проверок. Эти имена указывают на отдельный управляющий слой поверх VPN-ядра. Точный алгоритм выбора серверов, значения таймаутов, интервалы и правила автоматического переключения из AOT не подтверждены. Строки о retry из сторонних платёжных и HTTP-библиотек не использованы как свидетельство алгоритма VPN. Закрытый код, ресурсы и native source BlancVPN в QVPN не перенесены. + +В текущем исходном коде QVPN сохранён идентификатор последнего рабочего сервера; автоматический выбор удерживает допустимый рабочий сервер и перебирает ещё не отказавшие варианты. Ручной выбор остаётся фиксированным при отключённом автоматическом переключении. Отсутствующий TCP-замер отличается от явной ошибки измерения; если все TCP-замеры отрицательны, реальная VPN-попытка остаётся возможной. Восстановление учитывает изменения физического сетевого пути и сигнал пробуждения Windows; повторные команды подключения не запускают работающий туннель заново. Проверки доступности ограничены общим временем и используют несколько целей. Эти изменения реализуют соответствующие механизмы самостоятельно. Проверка исходников и регрессий должна дополняться отдельной проверкой установленного Windows-приложения; многочасовая стабильность и реальный сон/пробуждение из анализа APK не следуют. + +## Проверка Xray и свежести подписки + +В отдельной проверке на физическом Wi-Fi с остановленным HAPP оба ядра не передали HTTPS через сохранённые параметры Цюриха. TCP до сохранённого сервера отвечал примерно за 65 мс: такой замер не подтверждает исправность VLESS/Reality. Затем новая подписка была получена в памяти без изменения установленных настроек. Для той же именованной локации изменились адрес сервера, SNI и shortId; UUID, публичный ключ, fingerprint и flow остались прежними. + +С новыми параметрами официальный Xray 26.6.27 передал HTTPS 204: стандартная конфигурация — 272 мс после готовности локального прокси, без привязки интерфейса — 235 мс, с фрагментацией TLShello — 405 мс. Независимый curl через SOCKS подтвердил 204 во всех трёх случаях. Это проверка одного набора свежих параметров и трёх коротких попыток, а не измерение времени подключения телефона или полной Windows TUN-сессии. Фрагментация для этого соединения не потребовалась. + +Обнаружена ошибка прежнего управляющего слоя: автоматическое обновление подписки начиналось только после успешного соединения, поэтому неработающий кэш не обновлялся во время восстановления. Новый запрос подключения и запуск службы со старым кэшем получают параметры до запуска TUN, не блокируя команды интерфейса. Время такого обновления ограничено пятью секундами; после ошибки связи повторные автоматические запросы ограничены одной попыткой в минуту. Выбор географической локации сохраняется при смене параметров и ID. + +## Установленная версия 1.0.3 + +Установленная служба совпала по SHA-256 с собранным payload. Проверены рабочий Xray, Windows TUN и HTTPS 204 через системный маршрут. Повторные команды и прямые TCP-замеры не перезапустили соединение; обновление параметров подписки сохранило Цюрих. Аварийное завершение Xray восстановилось за 6,13 с, процесса TUN — за 8,22 с, службы — за 15,32 с без оставшихся дочерних процессов. В объединённом испытании первый снимок последующего наблюдения не совпал с ожидаемым; исходный отчёт не сохранил различавшиеся поля, и установить причину этого снимка не удалось. Проверочный скрипт дополнен проверкой принадлежности процесса текущей службе и записью сравниваемых полей. + +После обычного подключения пользователя к Цюриху проведено отдельное наблюдение установленной версии: 300,74 с, 58 снимков состояния, 10 успешных системных HTTPS-проверок, 0 перезапусков процессов и 0 смен сервера. На завершение проверки VPN оставлен подключённым. Время этого обычного подключения по событиям службы — примерно 3–4 с. Регрессионные проверки исходников: 49 групп, 49 успешно. Многосуточная работа и реальный сон/пробуждение в этой проверке не испытаны. diff --git a/research/happ-leanrea.md b/research/happ-leanrea.md new file mode 100644 index 0000000..f709725 --- /dev/null +++ b/research/happ-leanrea.md @@ -0,0 +1,88 @@ +# Happ 4.5.4: статическое исследование Windows-клиента, 24 октября 2026 + +Исследована копия `C:\Program Files\FlyFrogLLC\Happ\Happ.exe` в `C:\Tools\QVPN-private\happ-review-20261024\Happ.exe`. SHA-256 оригинала и копии совпал: `010A05548865A85DF250337E0C14CC137248B96C9A2446E4F2EFEAEB4455176A`. Размер — 21 260 712 байт. PE x64, ImageBase `0x140000000`; таблица COFF-символов отсутствует. Использованы PE-импорты Qt, встроенные строки, шаблоны JSON, unwind-границы функций и дизассемблирование. Настройки пользователя, базы, текущие журналы и секреты здесь не читались. Приложения, службы и сетевые настройки не изменялись. + +**Видно** означает наличие данных или соответствующей ветви машинного кода. **Вывод** означает интерпретацию этих данных. Статическое исследование не подтверждает выполнение ветви в текущем соединении. Имена C++ ниже восстановлены из встроенных диагностических строк, а не из отладочных символов. + +## Собственный TUN Xray + +**Видно:** встроенное имя `coreConfigs::TConfigBuilder::injectXrayTunInbound` находится по файловому смещению `0x46c210`, VA `0x14046d010`. Соответствующая функция ограничена unwind-таблицей: VA `0x1400ba700`–`0x1400bdad8`. Это отдельный генератор native TUN Xray, наряду с поддержкой сторонних TUN-процессов. + +| Параметр/ветвь | Доказательство | +|---|---| +| `tag=tun-in`, `protocol=tun` | строки: файл `0x46c114`, `0x45b2cc`; присваивание protocol: VA `0x1400bb56f`–`0x1400bb5b0` | +| Default нового TUN: `mtu=1500` | VA `0x1400bb0d5`: `mov edx,0x5dc`, затем `QJsonValue(int)`; ключ `mtu`: файл `0x46c13c` | +| `userLevel=8` | VA `0x1400bb132`–`0x1400bb171` | +| `desc=Happ` | строки `Happ`: файл `0x458198`, `desc`: `0x46c140`; присваивание VA `0x1400bb18f`–`0x1400bb1d0` | +| IPv4 gateway `172.19.0.1/30` | строка: файл `0x46c120`; создание массива VA `0x1400ba7c8`–`0x1400ba7f8` | +| DNS виртуального интерфейса `172.19.0.2` | строка: файл `0x46c150`; массив DNS и присваивание VA `0x1400bb24d`–`0x1400bb2c1` | +| IPv4 default route `0.0.0.0/0` | строка: файл `0x46c160`; массив `autoSystemRoutingTable`: VA `0x1400bb30b`–`0x1400bb3c8` | +| IPv6 gateway и default route добавляются условно | `fdfe:dcba:9877::1/126`: файл `0x46bd90`; `::/0`: файл `0x46c16c`; ветви VA `0x1400ba81a`–`0x1400ba844`, `0x1400bb357`–`0x1400bb384` | +| При наличии sniffing у SOCKS он копируется для TUN и получает `routeOnly=true` | чтение sniffing: VA `0x1400ba9ec`–`0x1400baadc`; bool true и ключ routeOnly: VA `0x1400bab4c`–`0x1400bab5b`, ключ: файл `0x46c130` | +| DNS-трафик отправляется в `dns-out` | конструкция routing rule с port `53`, network `tcp,udp`, outboundTag `dns-out`: VA `0x1400bc330`–`0x1400bc41b`; строки: файл `0x46c0e8`, `0x46c2e8` | + +**Видно: IPv6 по умолчанию выключен в этом генераторе.** Фабрика XrayTunOptions VA `0x1400cfbe0`–`0x1400cfe2e` вызывает getter VA `0x140153860`; затем сравнивает результат с `1` и сохраняет boolean в нулевой байт options (`0x1400cfc43`–`0x1400cfc51`). Getter читает ключ `Preferences/TunnelSettings/preferredIPType` (файл `0x4baae0`, VA `0x1404bb8e0`) с default `QVariant(int 0)` (VA `0x140153883`–`0x1401538ce`). Вторая возможность включить IPv6: одна из строк списка `Preferences/AdvancedSettings/excludedRoutes` содержит `:`. Ключ списка находится по файлу `0x4bb078`, VA `0x1404bbe78`; getter VA `0x14014fc90`; проверка `QString::contains(':')` — VA `0x1401c60e0`–`0x1401c60eb`, обход списка — VA `0x1400ba78b`–`0x1400ba7bf`. + +Текущее значение preferredIPType пользователя этим исследованием не установлено. Нельзя переносить статический default на активное соединение. Отличие от безусловного dual-stack TUN требует отдельного сопоставления активных настроек. Разница MTU с конфигурацией QVPN 1500 не обнаружена. + +## Выбор физического адаптера + +**Видно:** при создании native TUN вызывается helper VA `0x1401d4570` (вызов `0x1400bb44a`). Его строки сообщают о поиске интерфейса default route; он использует `GetAdaptersAddresses` для получения имени адаптера. Непустое имя присваивается `autoOutboundsInterface`; при неуспехе остаётся значение `auto` и выдаётся предупреждение (строка: файл `0x46c1a0`; ветвь VA `0x1400bb43c`–`0x1400bb546`; ключ: файл `0x46c2a8`). + +**Вывод:** генератор не выбирает просто первый перечисленный адаптер. Это не доказывает автоматическое переключение на другой физический адаптер после запуска: native TUN получает конкретное имя, когда resolver успешен. Алгоритм повторной генерации при смене сети здесь не исследован. + +## DNS: шаблоны, а не доказательство активной политики + +| Шаблон в Happ.exe | Статически видимое содержимое | +|---|---| +| файл `0x4682a0`, VA `0x1404690a0` | DNS hosts и queryStrategy параметризованы; servers содержит один `https://1.1.1.1/dns-query` | +| файл `0x468320`, VA `0x140469120` | другой шаблон: UDP DNS 1.1.1.1 и 8.8.8.8, port 53; подмена `domain:googleapis.cn` на googleapis.com | +| файл `0x468470` | несколько параметризованных DNS-серверов и domain-scoped правила | +| файлы `0x4685a0`, `0x4686e0` | варианты параметризованных серверов, domain-scoped правил и port/дополнительных параметров | +| файл `0x468800` | FakeDNS pool `198.18.0.0/15`, poolSize 10000 | + +**Вывод:** Happ поддерживает разные DNS-режимы. Утверждение, что именно резервный DNS обеспечивает работоспособность активного Happ, этими шаблонами не подтверждено. В одном из штатных шаблонов upstream единственный, как и в прежней конфигурации QVPN. Точный выбор шаблона и активные значения требуют отдельной runtime-проверки. + +## Mux, UDP443 и Reality + +| Шаблон/поле | Доказательство и граница вывода | +|---|---| +| Mux выключен | файл `0x468880`, VA `0x140469680`: enabled false, concurrency -1, xudpConcurrency 8, xudpProxyUDP443 пустая строка | +| Mux включён и настраивается | файл `0x4688f0`, VA `0x1404696f0`: enabled true, остальные три поля параметризованы | +| Поля GUI/настроек | `useMuxing`, `muxingTcpConcurrency`, `muxingXudpConcurrency`, `muxingHandleQUICMode`: файл `0x444ce8`–`0x444d1d` | +| TCP Reality | файл `0x469240`, VA `0x14046a040`: network tcp, header.type none; параметры fingerprint, publicKey, serverName, shortId, show, spiderX и security | +| Другие Reality-транспорты | gRPC Reality: файл `0x469810`; остальные шаблоны не устанавливают выбор активного узла | + +**Видно:** настройки Mux/QUIC конфигурируемы. **Не установлено:** активный UDP443 policy, включение Mux в рабочей сессии, автоматическое изменение flow. Нельзя объяснять превосходство Happ «включённым Mux» по наличию шаблона. Синтаксические варианты network tcp/raw и Reality publicKey/password сами по себе не доказывают разницу поведения совместимого ядра. + +## Разрешение доменного адреса сервера + +Встроенные диагностические строки показывают отдельный TServerAddressResolver: параллельные DoH A/AAAA (файл `0x463400`), проверку нескольких IP через временные VPN-ядра и proxy ping (файлы `0x463930`, `0x463988`, `0x463a40`), принятие первого рабочего ответа (`0x463780`) и возврат к исходному домену, если IP не ответили (`0x463868`). Это сильное свидетельство возможностей соответствующего компонента; его полный алгоритм не восстановлен. Применимость к текущим локациям с IP literal не подтверждена. + +## Практический результат для QVPN + +Статически подтверждены другой путь TUN (собственный Xray), условное IPv6, выбор default-route адаптера, маршрутизация DNS через отдельный outbound и сохранение исходной цели при sniffing. Не подтверждены активная DNS-резервируемость, включённый Mux, специальные значения UDP443 или фрагментация как причины работоспособности Happ. Замена всего TUN требует отдельной проверки очистки Windows-маршрутов, DNS, geo/routing и аварийного завершения; одно наличие генератора не доказывает безопасную взаимозаменяемость. + +Воспроизводимые локальные материалы: `pe-inventory.txt`, `shipped-strings.jsonl`, `configuration-strings.json`, `disassembly.txt`, `disassembly-annotated.txt`, `inject-xray-tun.dis.txt`, `xray-tun-options.dis.txt`, `default-route-adapter.dis.txt` и скрипты извлечения в каталоге копии. Никакие ресурсы или закрытый программный код Happ в исходники QVPN не переносились. + +## Отдельная служба Windows + +Исследована также копия `happd.exe`: SHA-256 `BDF6857524C1DB459282C3C96CD9C77C0AF78DA557B793F1D739452559748D20`, размер 3 699 624 байта. **Видно:** PE импортирует Windows IP Helper (`GetIpForwardTable2`, `CreateIpForwardEntry2`, `DeleteIpForwardEntry2`, `SetIpInterfaceEntry`) и WFP (`FwpmEngineOpen0`, `FwpmFilterAdd0`, `FwpmFilterGetById0`, `FwpmFilterDeleteById0`). Сами импорты не доказывают активность конкретного фильтра. + +Встроенные сообщения по файловым смещениям `0x244A00`, `0x247550`, `0x247B70`, `0x24D668`, `0x24D822` описывают обнаружение потерянного после сна адаптера, установку DNS, 15-секундный допуск heartbeat после пробуждения, разрешающие фильтры интерфейса и проверку сохранности WFP-фильтров. **Вывод:** управление соединением Happ включает отдельную политику Windows, а общий Xray и одинаковый ключ не определяют поведение всей системы. Эти строки не являются измерением работоспособности каждого пути в текущем сеансе. + +## Изменения QVPN 1.0.5 + +- `SystemTunnelProbe` использует Windows `GetBestInterfaceEx` и сверяет выбранный интерфейс для публичных контрольных IPv4/IPv6-адресов с индексом QVPN. Рабочий адаптер и HTTPS напрямую больше не могут сами по себе подтвердить VPN. Проверка выполняется в цикле службы; при потере маршрута статус подключения снимается, повторный отказ восстанавливает локальный туннель на прежнем сервере. Это проверка контрольных маршрутов, а не фильтрация всех обходных путей. +- `Subscription` ограничивает одну полную HTTP-попытку 12 секундами, включая тело после `ResponseHeadersRead`. Более короткая отмена вызывающего кода действует раньше; прежний один повтор сохранён. Устранён случай, когда пришедшие заголовки оставляли фоновое обновление ждать тело бессрочно. Семантика подтверждена [документацией Microsoft](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpcompletionoption). +- Результат проверки маршрутов `SystemRoutesVerified` доступен через существующий IPC и в диагностике интерфейса. Секреты подписки в него не входят. + +Регрессии: 70 проверок успешно, включая обход IPv4/IPv6 при поднятом TUN, неизвестные ошибки Windows route lookup, IPv4-only сеть, зависшее тело подписки и отмену. Живые результаты установки, восстановления и контролируемой потери маршрута сохраняются отдельно в `happ-comparison-verification.json`, `installed-stability-verification.json`, `system-route-verification.json`. + +24 октября установлена и проверена версия 1.0.5.0; подписка сохранена, 50 локаций. Подключение к Оулу заняло 3,67 с, системные DNS/HTTPS и сравнительная проверка внешнего выхода прошли. Принудительное завершение Xray, TUN-ядра и службы восстановилось на той же локации за 6,36, 11,24 и 9,07 с соответственно; дочерних процессов после сбоя службы не осталось. + +Отдельно добавлен один временный маршрут `1.1.1.1/32` через физическую сеть при поднятом адаптере QVPN. Обход обнаружен за 1,07 с: `SystemRoutesVerified=false`, состояние `connecting` вместо ложного `connected`. Локальный туннель перезапущен за 12,8 с без смены сервера; после удаления тестового маршрута проверенное подключение вернулось за 0,61 с. Удаление именно добавленного маршрута подтверждено. В последующих 61,5 с наблюдения было 27 снимков состояния, 6 успешных системных HTTPS-проверок, 0 самопроизвольных перезапусков и 0 смен сервера. Независимый завершающий HTTPS-запрос вернул 204. + +Установщик: `dist/QVPN-Setup-1.0.5.exe`, SHA-256 `B2518BDC3E96CE5CEE905CEE2A44252CA6424CF8771845F17E5F5B5A42D3501F`. Все 211 файлов payload совпали с манифестом. Первое обновление откатилось после отказа Windows переместить каталог программы; повторная установка завершилась успешно. Финальное состояние проверки: QVPN подключён к Оулу, служба Happ остановлена. + +Транспортное ядро уже совпадало с Happ до этих изменений; SHA-256 текущих бинарников Xray повторно совпал 24 октября. Перенос native TUN требует отдельной реализации DNS-политики, совместимости RU/private-правил и очистки после аварии: Xray использует `.dat`, а текущий QVPN — `.srs`; принудительное завершение процесса не гарантирует вызов native `Close()`. Поэтому в этой версии изменены проверенные дефекты управления существующим туннелем. Многодневная работа и реальный сон/пробуждение этим исследованием не доказаны. diff --git a/research/xray-release.json b/research/xray-release.json new file mode 100644 index 0000000..98f085a --- /dev/null +++ b/research/xray-release.json @@ -0,0 +1,28 @@ +{ + "tag": "v26.9.9", + "releaseUrl": "https://github.com/XTLS/Xray-core/releases/tag/v26.9.9", + "publishedAt": "2026-09-08T22:28:10Z", + "binaryArchive": { + "file": "tools/Xray-windows-64-v26.9.9.zip", + "url": "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-windows-64.zip", + "size": 21203133, + "sha256": "244deaba2098c2964e49bba90df3707777e5f5f428a82d2f29604015f24beec2", + "digestUrl": "https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-windows-64.zip.dgst" + }, + "sourceArchive": { + "file": "tools/Xray-core-v26.9.9-source.zip", + "url": "https://codeload.github.com/XTLS/Xray-core/zip/refs/tags/v26.9.9", + "sha256": "33871a5b7673418708beba3eaa76028f941ce77986003b24e48a0a1a5f01425e" + }, + "executable": { + "version": "26.9.9", + "revision": "52a412d", + "toolchain": "go1.27.1 windows/amd64", + "sha256": "0d0fc0ea2b05641acb78c01fc36ad694e7b029861b2d5eb93da0e3e9fda9a98f" + }, + "validation": "Windows archive SHA256 matches both official GitHub release API digest and official .dgst; source downloaded from official tagged codeload URL.", + "sourceRevision": "52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120", + "inspectedHappExecutableSha256": "0d0fc0ea2b05641acb78c01fc36ad694e7b029861b2d5eb93da0e3e9fda9a98f", + "inspectedHappBinaryIdentical": true, + "configValidation": "Passed four synthetic engine checks: Vision on/off, adapter binding on/off. Configuration parsing only; no live tunnel or upstream handshake performed." +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 index fff962e..05325cd 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -32,9 +32,13 @@ function Invoke-DotNet([string[]]$Arguments) { $coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64' $rulesRoot = Join-Path $projectRoot 'tools/rules' +$xrayRoot = Join-Path $projectRoot 'tools/xray-unpacked' foreach ($coreFile in @('sing-box.exe', 'libcronet.dll', 'LICENSE')) { if (-not (Test-Path -LiteralPath (Join-Path $coreRoot $coreFile) -PathType Leaf)) { throw "Missing packaged VPN core: $coreFile" } } +foreach ($xrayFile in @('xray.exe', 'wintun.dll', 'LICENSE', 'LICENSE-Wintun')) { + if (-not (Test-Path -LiteralPath (Join-Path $xrayRoot $xrayFile) -PathType Leaf)) { throw "Missing packaged Xray core: $xrayFile. Run scripts/prepare-xray.ps1." } +} if (-not (Test-Path -LiteralPath $rulesRoot) -or -not (Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File)) { throw 'Missing routing rules in tools/rules. The installer never downloads dependencies.' } @@ -55,12 +59,22 @@ Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.App/QVPN Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.Service/QVPN.Service.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=false', '-o', (Join-Path $payloadRoot 'service')) New-Item -ItemType Directory -Path (Join-Path $payloadRoot 'core'), (Join-Path $payloadRoot 'rules') -Force | Out-Null Copy-Item -LiteralPath (Join-Path $coreRoot 'sing-box.exe'), (Join-Path $coreRoot 'libcronet.dll'), (Join-Path $coreRoot 'LICENSE') -Destination (Join-Path $payloadRoot 'core') +Copy-Item -LiteralPath (Join-Path $xrayRoot 'xray.exe'), (Join-Path $xrayRoot 'wintun.dll'), (Join-Path $xrayRoot 'LICENSE-Wintun') -Destination (Join-Path $payloadRoot 'core') +Copy-Item -LiteralPath (Join-Path $xrayRoot 'LICENSE') -Destination (Join-Path $payloadRoot 'core/LICENSE-Xray') +Copy-Item -LiteralPath (Join-Path $projectRoot 'tools/Xray-core-v26.9.9-source.zip') -Destination (Join-Path $payloadRoot 'core/Xray-core-v26.9.9-source.zip') Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File | Copy-Item -Destination (Join-Path $payloadRoot 'rules') if (Test-Path -LiteralPath (Join-Path $projectRoot 'README.md')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'README.md') -Destination (Join-Path $payloadRoot 'README.md') } if (Test-Path -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING') -Destination (Join-Path $payloadRoot 'core/COPYING') } if (Test-Path -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip') -Destination (Join-Path $payloadRoot 'core/sing-box-1.14.2-source.zip') } @' +QVPN uses unmodified Xray-core v26.9.9 (Windows amd64), copyright Project X contributors, for VLESS/Reality. +Xray is distributed under Mozilla Public License 2.0 in core/LICENSE-Xray. +The original upstream source is included in core/Xray-core-v26.9.9-source.zip. +Source: https://github.com/XTLS/Xray-core/tree/v26.9.9 +Release: https://github.com/XTLS/Xray-core/releases/tag/v26.9.9 +Wintun license is included in core/LICENSE-Wintun. QVPN includes sing-box v1.14.2 (Windows amd64), copyright nekohasekai / SagerNet. +sing-box provides the Windows TUN, DNS handling and routing to the local Xray proxy. sing-box is distributed under GNU GPL v3 or later with the notice in core/LICENSE. Upstream source archive for the unmodified binary: core/sing-box-1.14.2-source.zip Source tree and module/build instructions: https://github.com/SagerNet/sing-box/tree/v1.14.2 diff --git a/scripts/compare-happ-installed.ps1 b/scripts/compare-happ-installed.ps1 new file mode 100644 index 0000000..5607161 --- /dev/null +++ b/scripts/compare-happ-installed.ps1 @@ -0,0 +1,197 @@ +[CmdletBinding()] +param([switch]$InstallUpdate, [switch]$ResumeInstalled, [int]$ObserveSeconds = 120) + +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' +$reportPath = Join-Path $repo 'research/happ-comparison-verification.json' +$happRoot = 'C:\Program Files\FlyFrogLLC\Happ' +$dataRoot = Join-Path $env:ProgramData 'QVPN' +$report = [ordered]@{ Completed=$false; Phase='preflight'; CapturedAt=[DateTimeOffset]::Now.ToString('o') } +$happWasRunning = $false +$before = $null +$backupPath = $null + +function Save-Phase([string]$phase) { + $report.Phase = $phase + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 +} +function Send([string]$command, [string]$value='', [Nullable[bool]]$enabled=$null) { + $arguments = @($dll, '--client', $command) + if ($value) { $arguments += @('--value', $value) } + if ($null -ne $enabled) { $arguments += @('--enabled', $enabled.ToString().ToLowerInvariant()) } + $response = & dotnet @arguments | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw "Service command failed: $command" } + return $response.Status +} +function Read-Settings { + $raw = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes((Join-Path $dataRoot 'settings.dpapi')), $null, [Security.Cryptography.DataProtectionScope]::LocalMachine) + try { return [Text.Encoding]::UTF8.GetString($raw) | ConvertFrom-Json } + finally { [Array]::Clear($raw, 0, $raw.Length) } +} +function Wait-Connected([int]$seconds=60) { + $timer = [Diagnostics.Stopwatch]::StartNew() + do { + try { $status = Send 'status' } catch { Start-Sleep -Milliseconds 400; continue } + if ($status.State -eq 'connected' -and $status.EnginePid -and $status.XrayPid) { + return [pscustomobject]@{Connected=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Status=$status} + } + Start-Sleep -Milliseconds 400 + } while ($timer.Elapsed.TotalSeconds -lt $seconds) + return [pscustomobject]@{Connected=$false;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Status=$status} +} +function Measure-Path([bool]$proxy) { + $results = @('https://cp.cloudflare.com/generate_204','https://www.gstatic.com/generate_204','https://www.google.com/generate_204') | ForEach-Object -Parallel { + $arguments = @('--silent','--head','--max-time','8','--output','NUL','--write-out','%{http_code}|%{time_total}|%{time_namelookup}') + if ($using:proxy) { $arguments += @('--proxy','http://127.0.0.1:17891') } else { $arguments += @('--noproxy','*') } + $raw = & curl.exe @arguments $_ 2>$null + $exitCode = $LASTEXITCODE + $fields = "$raw".Split('|') + [pscustomobject]@{Endpoint=([Uri]$_).Host;Http=[int]$fields[0];CurlExit=$exitCode;Seconds=$fields[1];DnsSeconds=$fields[2]} + } -ThrottleLimit 3 + return @($results) +} +function Measure-Download { + $raw = & curl.exe --silent --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}|%{time_total}|%{size_download}|%{speed_download}' 'https://speed.cloudflare.com/__down?bytes=1000000' 2>$null + $exitCode=$LASTEXITCODE; $fields="$raw".Split('|') + return [pscustomobject]@{Http=[int]$fields[0];CurlExit=$exitCode;Seconds=$fields[1];Bytes=[long]$fields[2];BytesPerSecond=$fields[3];Complete=($exitCode -eq 0 -and [long]$fields[2] -eq 1000000)} +} +function Read-Exit([bool]$proxy, [int]$port=17891) { + $arguments=@('--silent','--max-time','8') + if ($proxy) { $arguments+=@('--proxy',"socks5h://127.0.0.1:$port") } else { $arguments+=@('--noproxy','*') } + $value = & curl.exe @arguments 'https://api4.ipify.org' 2>$null + $parsed=$null + if ($LASTEXITCODE -eq 0 -and [Net.IPAddress]::TryParse("$value".Trim(), [ref]$parsed)) { return $parsed.ToString() } + return $null +} + +try { + if (!([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator required' } + if ($ObserveSeconds -lt 30 -or $ObserveSeconds -gt 600) { throw 'Observation must be between 30 and 600 seconds' } + $before=Send 'status'; $settings=Read-Settings + if (!$before.Nodes.Count) { throw 'Existing subscription required' } + $version=([xml](Get-Content -LiteralPath (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version + $setup=Join-Path $repo "dist/QVPN-Setup-$version.exe" + if ($InstallUpdate) { + $expected=(Get-Content -LiteralPath (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0] + if ((Get-FileHash -LiteralPath $setup).Hash -ne $expected) { throw 'Installer checksum mismatch' } + } + $backupPath=Join-Path $dataRoot ("settings.dpapi.before-$version-happ-"+[DateTime]::UtcNow.ToString('yyyyMMddHHmmss')) + Copy-Item -LiteralPath (Join-Path $dataRoot 'settings.dpapi') -Destination $backupPath + if ($ResumeInstalled) { + if ($InstallUpdate) { throw 'ResumeInstalled and InstallUpdate cannot be combined' } + $report=Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json -AsHashtable + if (!$report.Old -or !$report.Happ) { throw 'A completed reference baseline is required for resume' } + $report.Completed=$false + foreach ($key in @('Failure','OriginalSettingsRestored','NeedsHappUiRestore','RestoreFailureType')) { $report.Remove($key) } + $report.Reference.SameCachedEndpoint=$null + $report.Reference.HappEndpointAvailable=$false + $report.ExitCheckNote='Exit addresses can vary across new connections, including the same Xray SOCKS process. Compare each VPN path with the physical baseline instead of requiring identical VPN egress addresses.' + $node=$settings.Nodes | Where-Object {$_.Name -eq $report.Location} | Select-Object -First 1 + if (!$node) { throw 'The compared location is missing from installed settings' } + $happWasRunning=(Get-Service HappService).Status -eq 'Running' + Stop-Service HappService + $null=Send 'disconnect' + Save-Phase 'verify physical exit before installed tunnel' + $physicalExit=Read-Exit $false + } else { + $preferences=Get-ItemProperty 'HKCU:\Software\Happ\OrganizationDefaults\Preferences' + $node=$settings.Nodes | Where-Object {$_.Host -eq $preferences.lastServerHost -and $_.Port -eq [int]$preferences.lastServerPort} | Select-Object -First 1 + $sameEndpoint=$null -ne $node + if (!$node) { $node=$settings.Nodes | Where-Object {$_.Name -like ('*'+$preferences.lastServerName+'*')} | Select-Object -First 1 } + if (!$node) { throw 'Matching Happ location is absent from QVPN cache' } + $report.Location=$node.Name + $report.Reference=[ordered]@{HappVersion='4.5.4 (625)';HappTunProvider=(Get-ItemProperty 'HKCU:\Software\Happ\OrganizationDefaults\Preferences\AdvancedSettings').tunProvider;HappEndpointAvailable=[bool]$preferences.lastServerHost;SameCachedEndpoint=if($preferences.lastServerHost){$sameEndpoint}else{$null};QvpnFlow=$node.Flow;QvpnFingerprint=$node.Fingerprint;QvpnOldVersion=(Get-Item 'C:\Program Files\QVPN\service\QVPN.Service.exe').VersionInfo.FileVersion} + $happWasRunning=(Get-Service HappService).Status -eq 'Running' + if (!(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'})) { throw 'An active Happ tunnel is required for the reference baseline' } + $null=Send 'disconnect' + Save-Phase 'Happ baseline' + $report.Happ=[ordered]@{SystemHttps=(Measure-Path $false);Download=(Measure-Download)} + $happExit=Read-Exit $false + Save-Phase 'release Happ tunnel' + Get-Process Happ -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq (Join-Path $happRoot 'Happ.exe')} | Stop-Process -Force + Stop-Service HappService + (Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(20)) + $timer=[Diagnostics.Stopwatch]::StartNew() + while (Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'}) { + if ($timer.Elapsed.TotalSeconds -gt 20) { throw 'Happ tunnel did not stop' } + Start-Sleep -Milliseconds 300 + } + $physicalExit=Read-Exit $false + $null=Send 'select' $node.Id; $null=Send 'failover' '' $false + Save-Phase 'QVPN old baseline' + $null=Send 'connect'; $connection=Wait-Connected 45 + $report.Old=[ordered]@{Connected=$connection.Connected;ConnectSeconds=$connection.Seconds;ActiveName=$connection.Status.ActiveName;SystemHttps=(Measure-Path $false);ProxyHttps=(Measure-Path $true);Download=(Measure-Download)} + $null=Send 'disconnect' + if ($InstallUpdate) { + Save-Phase 'install update with encrypted settings preserved' + for ($attempt=0; $attempt -lt 2; $attempt++) { + $installer=Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait + if ($installer.ExitCode -eq 0) { break } + if ($attempt -eq 1) { throw 'Installer failed after two attempts; previous version rollback retained' } + Start-Sleep -Seconds 5 + } + } + } + $afterSettings=Read-Settings + $report.SubscriptionRetained=$afterSettings.SubscriptionUrl -ceq $settings.SubscriptionUrl + if (!$report.SubscriptionRetained) { throw 'Subscription changed during installation' } + $null=Send 'failover' '' $before.AutoFailover + Save-Phase 'connect updated QVPN' + $null=Send 'connect'; $connection=Wait-Connected + if (!$connection.Connected) { throw 'Updated system VPN did not connect' } + if ([version]$version -ge [version]'1.0.5' -and !$connection.Status.SystemRoutesVerified) { throw 'Updated VPN did not verify Windows route ownership' } + $report.New=[ordered]@{Version=(Get-Item 'C:\Program Files\QVPN\service\QVPN.Service.exe').VersionInfo.FileVersion;ConnectSeconds=$connection.Seconds;ActiveName=$connection.Status.ActiveName;SystemHttps=(Measure-Path $false);ProxyHttps=(Measure-Path $true);Download=(Measure-Download)} + $report.New.SystemRoutesVerified=$connection.Status.SystemRoutesVerified + $systemExits=@(1..3 | ForEach-Object { Read-Exit $false } | Where-Object {$_}) + $proxyExits=@(1..3 | ForEach-Object { Read-Exit $true } | Where-Object {$_}) + # An upstream VPN can assign a different egress IP to each fresh connection. + # Require both paths to differ from the physical connection, not from each other. + $report.New.ExitVerified=($physicalExit -and $systemExits.Count -ge 2 -and $proxyExits.Count -ge 2 -and !($systemExits -contains $physicalExit) -and !($proxyExits -contains $physicalExit)) + $report.New.SystemExitSamples=$systemExits.Count + $report.New.ProxyExitSamples=$proxyExits.Count + $report.New.DistinctProxyExits=@($proxyExits | Sort-Object -Unique).Count + if (!$ResumeInstalled) { $report.Reference.HappExitDiffersFromPhysical=($happExit -and $physicalExit -and $happExit -ne $physicalExit) } + $report.New.SystemRouteUsesQvpn=@(Find-NetRoute -RemoteIPAddress '1.1.1.1' | Where-Object {$_.InterfaceAlias -eq 'QVPN'}).Count -gt 0 + if (!$report.New.SystemRouteUsesQvpn) { throw 'System route does not use QVPN' } + if (!$report.New.ExitVerified) { throw 'System/proxy/physical exit comparison did not verify the VPN' } + if (!($report.New.SystemHttps | Where-Object {$_.Http -eq 204 -and $_.CurlExit -eq 0})) { throw 'System HTTPS failed after update' } + if (!($report.New.ProxyHttps | Where-Object {$_.Http -eq 204 -and $_.CurlExit -eq 0})) { throw 'Proxy HTTPS failed after update' } + if (!$report.New.Download.Complete) { throw 'Controlled download did not complete after update' } + if ($report.New.ActiveName -ne $node.Name) { throw 'Comparison location changed' } + Save-Phase 'recovery and passive observation' + $verification=Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -WindowStyle Hidden -ArgumentList @('-NoProfile','-File',(Join-Path $PSScriptRoot 'verify-stability-installed.ps1'),'-ObserveSeconds',$ObserveSeconds.ToString()) -PassThru -Wait + if ($verification.ExitCode -ne 0) { throw 'Installed recovery/stability verification failed' } + $report.RecoveryReport='installed-stability-verification.json' + # Return the original manual/automatic preference. The verified connection stays up. + $current=Send 'status' + if (!$before.SelectedId) { $null=Send 'select' } + elseif ($before.SelectedId -ne $node.Id) { + $oldSelected=$settings.Nodes | Where-Object {$_.Id -eq $before.SelectedId} | Select-Object -First 1 + $replacement=$current.Nodes | Where-Object {$_.Id -eq $before.SelectedId} | Select-Object -First 1 + if (!$replacement) { + $matches=@($current.Nodes | Where-Object {$_.Name -eq $oldSelected.Name}) + if ($matches.Count -eq 1) { $replacement=$matches[0] } + } + if ($replacement) { $null=Send 'select' $replacement.Id } + } + $final=Wait-Connected + if (!$final.Connected) { throw 'Final connection did not recover after restoring selection' } + $report.Final=[ordered]@{State=$final.Status.State;Location=$final.Status.ActiveName;Nodes=$final.Status.Nodes.Count;HappStopped=((Get-Service HappService).Status -eq 'Stopped')} + $report.Completed=$true; Save-Phase 'complete' +} catch { + $report.Failure=$_.Exception.Message + try { + $null=Send 'disconnect' + if ($backupPath -and (Test-Path -LiteralPath $backupPath)) { + Stop-Service QVPNService + (Get-Service QVPNService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(20)) + Copy-Item -LiteralPath $backupPath -Destination (Join-Path $dataRoot 'settings.dpapi') -Force + Start-Service QVPNService + $report.OriginalSettingsRestored=$true + } + } catch { $report.RestoreFailureType=$_.Exception.GetType().Name } + if ($happWasRunning) { Start-Service HappService -ErrorAction SilentlyContinue; $report.NeedsHappUiRestore=$true } + Save-Phase $report.Phase + exit 1 +} diff --git a/scripts/prepare-xray.ps1 b/scripts/prepare-xray.ps1 new file mode 100644 index 0000000..1e3e1ad --- /dev/null +++ b/scripts/prepare-xray.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$toolsRoot = Join-Path $root 'tools' +$archive = Join-Path $toolsRoot 'Xray-windows-64-v26.9.9.zip' +$source = Join-Path $toolsRoot 'Xray-core-v26.9.9-source.zip' +# SHA256 published in the official GitHub release API and Xray-windows-64.zip.dgst. +$expected = '244DEABA2098C2964E49BBA90DF3707777E5F5F428A82D2F29604015F24BEEC2' +New-Item -ItemType Directory -Path $toolsRoot -Force | Out-Null +if (!(Test-Path -LiteralPath $archive)) { Invoke-WebRequest -Uri 'https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-windows-64.zip' -OutFile $archive -TimeoutSec 240 } +if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $expected) { throw 'Xray upstream archive checksum mismatch' } +if (!(Test-Path -LiteralPath $source)) { Invoke-WebRequest -Uri 'https://codeload.github.com/XTLS/Xray-core/zip/refs/tags/v26.9.9' -OutFile $source -TimeoutSec 240 } +# Pin the source ZIP fetched from the official v26.9.9 tag alongside this binary. +if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ne '33871A5B7673418708BEBA3EAA76028F941CE77986003B24E48A0A1A5F01425E') { throw 'Xray upstream source archive checksum mismatch' } +Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $toolsRoot 'xray-unpacked') -Force +Write-Host 'Verified Xray-core v26.9.9 prepared (same 52a412d core revision as the inspected Happ Windows client).' diff --git a/scripts/verify-stability-installed.ps1 b/scripts/verify-stability-installed.ps1 new file mode 100644 index 0000000..1e0b335 --- /dev/null +++ b/scripts/verify-stability-installed.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param([switch]$InstallUpdate, [int]$ObserveSeconds = 300) + +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' +$root = Join-Path $env:ProgramFiles 'QVPN' +$report = Join-Path $repo 'research/installed-stability-verification.json' +$checks = [Collections.Generic.List[object]]::new() +$observationMismatch = $null + +function Send([string]$command, [string]$value = '') { + $arguments = @($dll, '--client', $command) + if ($value) { $arguments += @('--value', $value) } + $response = & dotnet @arguments | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw "Service command failed: $command" } + return $response.Status +} +function Wait-Connected([int]$oldEngine = 0, [int]$oldService = 0) { + $timer = [Diagnostics.Stopwatch]::StartNew() + do { + try { + $status = Send 'status' + $service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'" + if ($status.State -eq 'connected' -and $status.DesiredConnected -and $status.EnginePid -and + (!$oldEngine -or $status.EnginePid -ne $oldEngine) -and (!$oldService -or $service.ProcessId -ne $oldService)) { + $ownedEngine = Get-CimInstance Win32_Process -Filter "ProcessId=$($status.EnginePid)" + if ($ownedEngine -and $ownedEngine.ParentProcessId -eq $service.ProcessId) { return $status } + } + } catch { } + Start-Sleep -Milliseconds 500 + } while ($timer.Elapsed.TotalSeconds -lt 90) + throw 'A verified connection was not restored within 90 seconds' +} +function Verify-SystemHttps { + foreach ($url in @('https://cp.cloudflare.com/generate_204', 'https://www.google.com/generate_204', 'https://www.gstatic.com/generate_204')) { + $code = & curl.exe --silent --noproxy '*' --max-time 8 --output NUL --write-out '%{http_code}' $url + if ($LASTEXITCODE -eq 0 -and $code -eq '204') { return } + } + throw 'System HTTPS through the TUN failed' +} +function Assert-InstalledProcess([int]$processId, [string]$relativePath) { + $process = Get-Process -Id $processId + if ($process.Path -ne (Join-Path $root $relativePath)) { throw 'Unexpected process executable' } +} + +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if (!([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator token is required' } + if ($ObserveSeconds -lt 30 -or $ObserveSeconds -gt 3600) { throw 'Observation must be between 30 and 3600 seconds' } + $before = Send 'status' + if (!$before.DesiredConnected -or !$before.Nodes.Count) { throw 'An existing active subscription is required' } + $version = ([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version + if ($InstallUpdate) { + $data = Join-Path $env:ProgramData 'QVPN' + $encryptedSettings = Join-Path $data 'settings.dpapi' + Copy-Item -LiteralPath $encryptedSettings -Destination (Join-Path $data ("settings.dpapi.before-$version-" + [DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))) + $setup = Join-Path $repo "dist/QVPN-Setup-$version.exe" + $expected = (Get-Content (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0] + if ((Get-FileHash -LiteralPath $setup).Hash -ne $expected) { throw 'Installer hash mismatch' } + $installer = Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait + if ($installer.ExitCode -ne 0) { throw 'Installer failed' } + } + $timer = [Diagnostics.Stopwatch]::StartNew() + $status = Wait-Connected + if ($status.VpnCore -ne 'Xray' -or !$status.XrayPid) { throw 'Xray transport is not active' } + Assert-InstalledProcess $status.XrayPid 'core/xray.exe' + if ($status.Nodes.Count -ne $before.Nodes.Count -or $status.SelectedId -ne $before.SelectedId) { throw 'Subscription or selected location changed during update' } + $service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'" + if ($service.State -ne 'Running' -or $service.StartMode -ne 'Auto' -or $service.StartName -ne 'LocalSystem') { throw 'Service configuration mismatch' } + $adapter = Get-NetAdapter -Name QVPN + if ($adapter.Status -ne 'Up') { throw 'System TUN is not up' } + if (!$status.SystemRoutesVerified) { throw 'System TUN does not own the control routes' } + Verify-SystemHttps + $checks.Add([pscustomobject]@{Scenario='Installed update retains subscription and system TUN';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Nodes=$status.Nodes.Count;Version=(Get-Item (Join-Path $root 'service/QVPN.Service.exe')).VersionInfo.FileVersion}) + + $engine = $status.EnginePid; $active = $status.ActiveId + $null = Send 'connect' + $null = Send 'select' $status.SelectedId + $status = Send 'status' + if ($status.EnginePid -ne $engine) { throw 'Repeated connection/selection command restarted the engine' } + $checks.Add([pscustomobject]@{Scenario='Repeated commands preserve working tunnel';Passed=$true}) + $timer.Restart(); $null = Send 'measure' + do { + $status = Send 'status' + if ($status.Nodes | Where-Object {$null -ne $_.LatencyMs}) { break } + Start-Sleep -Milliseconds 500 + } while ($timer.Elapsed.TotalSeconds -lt 20) + if (!($status.Nodes | Where-Object {$null -ne $_.LatencyMs})) { throw 'Direct latency measurements did not complete' } + if ($status.EnginePid -ne $engine -or $status.ActiveId -ne $active) { throw 'Measurement interrupted the working tunnel' } + $checks.Add([pscustomobject]@{Scenario='Direct server measurements preserve working tunnel';Passed=$true;Reachable=@($status.Nodes | Where-Object {$null -ne $_.LatencyMs}).Count}) + + $selectedName = ($status.Nodes | Where-Object {$_.Id -eq $status.SelectedId} | Select-Object -First 1).Name + $activeName = $status.ActiveName + $timer.Restart(); $null = Send 'refresh'; $status = Wait-Connected + $newSelectedName = ($status.Nodes | Where-Object {$_.Id -eq $status.SelectedId} | Select-Object -First 1).Name + if ($selectedName -and $newSelectedName -ne $selectedName) { throw 'Subscription rotation lost the selected location' } + if ($status.ActiveName -ne $activeName) { throw 'Subscription rotation lost the working location' } + $checks.Add([pscustomobject]@{Scenario='Subscription refresh/credential rotation preserves selected location';Passed=$true;ParametersRotated=($status.EnginePid -ne $engine);Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)}) + $engine = $status.EnginePid; $active = $status.ActiveId + + $oldXray = $status.XrayPid; Assert-InstalledProcess $oldXray 'core/xray.exe' + $timer.Restart(); Stop-Process -Id $oldXray -Force + $status = Wait-Connected $engine + if ($status.XrayPid -eq $oldXray -or $status.ActiveId -ne $active) { throw 'Xray crash was not restored on the same server' } + Verify-SystemHttps + $checks.Add([pscustomobject]@{Scenario='Xray transport crash reconnects to the same working server';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)}) + $engine = $status.EnginePid + + Assert-InstalledProcess $engine 'core/sing-box.exe' + $timer.Restart(); Stop-Process -Id $engine -Force + $status = Wait-Connected $engine + if ($status.ActiveId -ne $active) { throw 'Local engine crash unnecessarily changed the working server' } + Verify-SystemHttps + $checks.Add([pscustomobject]@{Scenario='Engine crash reconnects to the same working server';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)}) + + $engine = $status.EnginePid; $oldXray = $status.XrayPid + $service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'" + Assert-InstalledProcess $service.ProcessId 'service/QVPN.Service.exe' + $oldService = $service.ProcessId; $timer.Restart(); Stop-Process -Id $oldService -Force + Start-Sleep -Milliseconds 1500 + if (Get-Process -Id $engine -ErrorAction SilentlyContinue) { throw 'Orphan VPN engine survived service crash' } + if (Get-Process -Id $oldXray -ErrorAction SilentlyContinue) { throw 'Orphan Xray transport survived service crash' } + $status = Wait-Connected $engine $oldService + if ($status.ActiveId -ne $active) { throw 'Service restart lost the last working server' } + Verify-SystemHttps + $checks.Add([pscustomobject]@{Scenario='Service crash restores persisted working server without orphan engine';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)}) + + $engine = $status.EnginePid; $restarts = $status.Restarts + $timer.Restart(); $samples = 0; $httpsChecks = 0 + do { + $status = Send 'status'; $samples++ + if ($status.EnginePid -ne $engine -or $status.ActiveId -ne $active -or $status.Restarts -ne $restarts -or $status.State -ne 'connected' -or !$status.SystemRoutesVerified) { + $observationMismatch = [pscustomobject]@{ + Expected=[pscustomobject]@{EnginePid=$engine;ActiveId=$active;Restarts=$restarts;State='connected'} + Observed=($status | Select-Object EnginePid,XrayPid,ActiveId,Restarts,State,SystemRoutesVerified) + } + throw 'Tunnel changed or lost checked connection during observation' + } + if ($samples % 6 -eq 1) { Verify-SystemHttps; $httpsChecks++ } + Start-Sleep -Seconds 5 + } while ($timer.Elapsed.TotalSeconds -lt $ObserveSeconds) + $checks.Add([pscustomobject]@{Scenario='Continuous installed TUN observation';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Samples=$samples;SystemHttpsChecks=$httpsChecks;UnplannedRestarts=0;ServerChanges=0}) + [pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Checks=$checks.ToArray();SleepWakeTested=$false} | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $report -Encoding utf8 +} catch { + [pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.Message;Checks=$checks.ToArray();ObservationMismatch=$observationMismatch} | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $report -Encoding utf8 + exit 1 +} diff --git a/scripts/verify-system-routes-installed.ps1 b/scripts/verify-system-routes-installed.ps1 new file mode 100644 index 0000000..ccebf0e --- /dev/null +++ b/scripts/verify-system-routes-installed.ps1 @@ -0,0 +1,93 @@ +[CmdletBinding()] +param([int]$ObserveSeconds = 60) + +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' +$reportPath = Join-Path $repo 'research/system-route-verification.json' +$report = [ordered]@{Completed=$false;Phase='preflight';CapturedAt=[DateTimeOffset]::Now.ToString('o')} +$injected = $null + +function Save([string]$phase) { + $report.Phase=$phase + $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding utf8 +} +function Status { + $response = & dotnet $dll --client status | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw 'QVPN status is unavailable' } + return $response.Status +} +function Remove-InjectedRoute { + if ($null -eq $script:injected) { return } + # Only this freshly created ActiveStore /32 is owned by the verifier. + if ($script:injected.DestinationPrefix -ne '1.1.1.1/32' -or $script:injected.RouteMetric -ne 1) { throw 'Unexpected route identity; refusing cleanup' } + $script:injected | Remove-NetRoute -Confirm:$false -ErrorAction Stop + $script:injected=$null +} + +try { + if (!([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator required' } + if ($ObserveSeconds -lt 30 -or $ObserveSeconds -gt 600) { throw 'Observation must be between30 and600 seconds' } + $before=Status + if ($before.State -ne 'connected' -or !$before.SystemRoutesVerified -or !$before.EnginePid) { throw 'An installed VPN with verified routes is required' } + $report.Before=$before | Select-Object State,ActiveName,EnginePid,XrayPid,Restarts,SystemRoutesVerified + if (Get-NetRoute -DestinationPrefix '1.1.1.1/32' -ErrorAction SilentlyContinue) { throw 'A pre-existing control host route must be preserved; test skipped' } + $physical=Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { + Get-NetRoute -InterfaceIndex $_.ifIndex -DestinationPrefix '0.0.0.0/0' -PolicyStore ActiveStore -ErrorAction SilentlyContinue + } | Where-Object {$_.NextHop -ne '0.0.0.0'} | Sort-Object RouteMetric | Select-Object -First 1 + if (!$physical) { throw 'No physical IPv4 default route found' } + Save 'inject one temporary public control route' + $timer=[Diagnostics.Stopwatch]::StartNew() + $injected=New-NetRoute -DestinationPrefix '1.1.1.1/32' -InterfaceIndex $physical.InterfaceIndex -NextHop $physical.NextHop -RouteMetric 1 -PolicyStore ActiveStore + $detected=$null; $restarted=$null + do { + $status=Status + if ($status.SystemRoutesVerified -eq $false -and !$detected) { + $detected=$status + $report.DetectionSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2) + $report.Degraded=$status | Select-Object State,EnginePid,Restarts,SystemRoutesVerified + if ($status.State -eq 'connected') { throw 'False connected status survived route loss' } + Save 'route loss detected; await same-server repair' + } + if ($status.EnginePid -and $status.EnginePid -ne $before.EnginePid) { $restarted=$status; break } + Start-Sleep -Milliseconds 200 + } while ($timer.Elapsed.TotalSeconds -lt 50) + if (!$detected) { throw 'Route bypass was not detected' } + if (!$restarted) { throw 'Local tunnel repair did not restart the engine' } + $report.RestartSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2) + Remove-InjectedRoute + Save 'control route restored; verify recovery' + $timer.Restart() + do { + $status=Status + if ($status.State -eq 'connected' -and $status.SystemRoutesVerified) { break } + Start-Sleep -Milliseconds 300 + } while ($timer.Elapsed.TotalSeconds -lt 60) + if ($status.State -ne 'connected' -or !$status.SystemRoutesVerified) { throw 'Verified VPN routes did not recover' } + if ($status.ActiveId -ne $before.ActiveId -or $status.SelectedId -ne $before.SelectedId) { throw 'A local route fault changed the selected VPN server' } + $report.RecoverySeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2) + $report.After=$status | Select-Object State,ActiveName,EnginePid,XrayPid,Restarts,SystemRoutesVerified + $baseline=$status; $samples=0; $httpsChecks=0 + Save 'passive observation' + $timer.Restart() + do { + $status=Status; $samples++ + if ($status.State -ne 'connected' -or !$status.SystemRoutesVerified -or $status.EnginePid -ne $baseline.EnginePid -or $status.ActiveId -ne $baseline.ActiveId) { throw 'Verified tunnel changed during passive observation' } + if ($samples % 5 -eq 1) { + $code=& curl.exe --silent --noproxy '*' --max-time 8 --output NUL --write-out '%{http_code}' 'https://cp.cloudflare.com/generate_204' 2>$null + if ($LASTEXITCODE -ne 0 -or $code -ne '204') { throw 'System HTTPS failed after route recovery' } + $httpsChecks++ + } + Start-Sleep -Seconds 2 + } while ($timer.Elapsed.TotalSeconds -lt $ObserveSeconds) + $report.Observation=[ordered]@{Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Samples=$samples;SystemHttpsChecks=$httpsChecks;UnplannedRestarts=0;ServerChanges=0} + $report.RouteCleanupVerified=!(Get-NetRoute -DestinationPrefix '1.1.1.1/32' -ErrorAction SilentlyContinue) + if (!$report.RouteCleanupVerified) { throw 'Temporary control route still exists' } + $report.Completed=$true; Save 'complete' +} catch { + $report.Failure=$_.Exception.Message + Save $report.Phase + exit 1 +} finally { + Remove-InjectedRoute +} diff --git a/scripts/verify-xray-upgrade.ps1 b/scripts/verify-xray-upgrade.ps1 new file mode 100644 index 0000000..8803560 --- /dev/null +++ b/scripts/verify-xray-upgrade.ps1 @@ -0,0 +1,72 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' +$report = Join-Path $repo 'research/xray-upgrade-verification.json' +$phase = 'preflight'; $happWasRunning = $false +function Save-Phase([string]$name) { + $script:phase = $name + [pscustomobject]@{Completed=$false;Phase=$name;At=[DateTimeOffset]::Now.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8 +} +function Send([string]$command,[string]$value='') { + $arguments=@($dll,'--client',$command); if($value){$arguments+=@('--value',$value)} + $response=& dotnet @arguments | ConvertFrom-Json + if($LASTEXITCODE -ne 0 -or !$response.Ok){throw "Service command failed: $command"} + return $response.Status +} +try { + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + if(!([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator required'} + $version=([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version + $setup=Join-Path $repo "dist/QVPN-Setup-$version.exe" + $expected=(Get-Content (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0] + if((Get-FileHash -LiteralPath $setup).Hash -ne $expected){throw 'Installer checksum mismatch'} + $before=Send 'status'; if(!$before.Nodes.Count){throw 'Cached subscription missing'} + $zurich=$before.Nodes | Where-Object {$_.Name -like '*Цюрих*'} | Select-Object -First 1 + if(!$zurich){throw 'Zurich is missing from subscription'} + Save-Phase 'refresh current provider parameters' + $data=Join-Path $env:ProgramData 'QVPN' + Copy-Item -LiteralPath (Join-Path $data 'settings.dpapi') -Destination (Join-Path $data ("settings.dpapi.before-$version-"+[DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))) + $refreshed=Send 'refresh' + $zurich=$refreshed.Nodes | Where-Object {$_.Name -eq $zurich.Name} | Select-Object -First 1 + if(!$zurich){throw 'Zurich is missing after refreshing provider parameters'} + Save-Phase 'release competing HAPP tunnel' + $happ=Get-Service HappService -ErrorAction SilentlyContinue + $happWasRunning=$happ -and $happ.Status -eq 'Running' + Get-Process Happ -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq 'C:\Program Files\FlyFrogLLC\Happ\Happ.exe'} | Stop-Process -Force + if($happWasRunning){Stop-Service HappService; (Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30))} + Start-Sleep -Seconds 2 + if(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'}){throw 'HAPP adapter is still active'} + $null=Send 'select' $zurich.Id + Save-Phase 'install Xray update preserving encrypted settings' + $timer=[Diagnostics.Stopwatch]::StartNew() + for($attempt=0;$attempt -lt 2;$attempt++) { + $installer=Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait + if($installer.ExitCode -eq 0){break} + if($attempt -eq 1){throw 'Installer failed twice'} + Start-Sleep -Seconds 5 + } + Save-Phase 'connect installed Xray to Zurich' + $null=Send 'connect'; $timer.Restart() + do { + Start-Sleep -Milliseconds 250 + $status=Send 'status' + if($status.State -eq 'connected' -and $status.VpnCore -eq 'Xray' -and $status.XrayPid){break} + }while($timer.Elapsed.TotalSeconds -lt 60) + if($status.State -ne 'connected' -or $status.VpnCore -ne 'Xray'){throw 'Installed Xray connection did not complete'} + if($status.ActiveName -ne $zurich.Name){throw 'Selected Zurich was not retained'} + $connectSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2) + Save-Phase 'verify installed Xray recovery and continuous TUN traffic' + $verification=Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',(Join-Path $PSScriptRoot 'verify-stability-installed.ps1'),'-ObserveSeconds','300') -PassThru -Wait + if($verification.ExitCode -ne 0){throw 'Installed recovery/stability verification failed'} + # Return the competing client to the state observed before this diagnostic run. + # The updated QVPN service and selected location remain installed for normal use. + if($happWasRunning -and !$before.DesiredConnected){$null=Send 'disconnect'; Start-Service HappService} + [pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Version=$version;ConnectionSeconds=$connectSeconds;VpnCore='Xray';Location='Zurich';KeyRetained=$true;HappStopped=$happWasRunning;HappRestored=($happWasRunning -and !$before.DesiredConnected);RecoveryReport='installed-stability-verification.json'} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8 +} catch { + try{$null=Send 'disconnect'}catch{} + if($happWasRunning){Start-Service HappService -ErrorAction SilentlyContinue} + [pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Phase=$phase;Failure=$_.Exception.Message;HappRestored=$happWasRunning} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8 + exit 1 +} diff --git a/src/QVPN.App/MainWindow.xaml.cs b/src/QVPN.App/MainWindow.xaml.cs index cb33e8c..99d4776 100644 --- a/src/QVPN.App/MainWindow.xaml.cs +++ b/src/QVPN.App/MainWindow.xaml.cs @@ -327,7 +327,7 @@ public partial class MainWindow : Window private string DiagnosticsReport() { - var report = new StringBuilder("QVPN 1.0.1 · диагностика\n"); + var report = new StringBuilder($"QVPN {typeof(MainWindow).Assembly.GetName().Version!.ToString(3)} · диагностика\n"); report.AppendLine($"Время: {DateTimeOffset.Now:O}"); report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}"); if (_availability is { } availability) @@ -338,6 +338,7 @@ public partial class MainWindow : Window report.AppendLine($"VPN запрошен: {status.DesiredConnected}"); report.AppendLine($"Сообщение: {status.Message}"); report.AppendLine($"Серверов: {status.Nodes.Count}; перезапусков: {status.Restarts}; PID ядра: {status.EnginePid}"); + report.AppendLine($"Системные маршруты через VPN: {(status.SystemRoutesVerified is null ? "не проверены" : status.SystemRoutesVerified.Value ? "подтверждены" : "не подтверждены")}"); report.AppendLine($"Российские сайты напрямую: {status.RussianDirect}; автопереход: {status.AutoFailover}"); report.AppendLine("\nСобытия службы:"); foreach (var entry in status.Events) report.AppendLine(entry); diff --git a/src/QVPN.Core/ConfigBuilder.cs b/src/QVPN.Core/ConfigBuilder.cs index e7c71fe..ca625c1 100644 --- a/src/QVPN.Core/ConfigBuilder.cs +++ b/src/QVPN.Core/ConfigBuilder.cs @@ -5,6 +5,25 @@ namespace QVPN.Core; public static class ConfigBuilder { public static JsonObject Build(VpnNode node, bool tun, bool russianDirect, string rulesDirectory, int port = Constants.ProxyPort) + => BuildRouting(new JsonObject + { + ["type"] = "vless", ["tag"] = "proxy", ["server"] = node.Host, ["server_port"] = node.Port, ["uuid"] = node.Uuid, + ["flow"] = node.Flow, ["connect_timeout"] = "10s", ["tls"] = new JsonObject + { + ["enabled"] = true, ["server_name"] = node.ServerName, + ["utls"] = new JsonObject { ["enabled"] = true, ["fingerprint"] = node.Fingerprint }, + ["reality"] = new JsonObject { ["enabled"] = true, ["public_key"] = node.PublicKey, ["short_id"] = node.ShortId } + } + }, tun, russianDirect, rulesDirectory, port); + + public static JsonObject BuildXrayBridge(bool tun, bool russianDirect, string rulesDirectory, int port = Constants.ProxyPort, int xrayPort = Constants.XrayPort) + => BuildRouting(new JsonObject + { + ["type"] = "socks", ["tag"] = "proxy", ["server"] = "127.0.0.1", ["server_port"] = xrayPort, + ["version"] = "5", ["connect_timeout"] = "5s" + }, tun, russianDirect, rulesDirectory, port); + + private static JsonObject BuildRouting(JsonObject proxy, bool tun, bool russianDirect, string rulesDirectory, int port) { var root = new JsonObject { @@ -15,16 +34,7 @@ public static class ConfigBuilder ["final"] = "remote-dns", ["strategy"] = "prefer_ipv4" }, ["inbounds"] = new JsonArray(new JsonObject { ["type"] = "mixed", ["tag"] = "health-in", ["listen"] = "127.0.0.1", ["listen_port"] = port }), - ["outbounds"] = new JsonArray(new JsonObject - { - ["type"] = "vless", ["tag"] = "proxy", ["server"] = node.Host, ["server_port"] = node.Port, ["uuid"] = node.Uuid, - ["flow"] = node.Flow, ["connect_timeout"] = "10s", ["tls"] = new JsonObject - { - ["enabled"] = true, ["server_name"] = node.ServerName, - ["utls"] = new JsonObject { ["enabled"] = true, ["fingerprint"] = node.Fingerprint }, - ["reality"] = new JsonObject { ["enabled"] = true, ["public_key"] = node.PublicKey, ["short_id"] = node.ShortId } - } - }, new JsonObject { ["type"] = "direct", ["tag"] = "direct" }) + ["outbounds"] = new JsonArray(proxy, new JsonObject { ["type"] = "direct", ["tag"] = "direct" }) }; if (tun) ((JsonArray)root["inbounds"]!).Add(new JsonObject { diff --git a/src/QVPN.Core/DirectLatencyProbe.cs b/src/QVPN.Core/DirectLatencyProbe.cs new file mode 100644 index 0000000..e58aebe --- /dev/null +++ b/src/QVPN.Core/DirectLatencyProbe.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace QVPN.Core; + +public sealed record PhysicalNetworkState(bool Available, string PathKey); + +public static class DirectLatencyProbe +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); + // ws2ipdef.h: IP_UNICAST_IF and IPV6_UNICAST_IF. The .NET enum does + // not define these Windows options, but forwards their numeric value. + private const SocketOptionName UnicastInterface = (SocketOptionName)31; + + public static string? GetInterfaceName(AddressFamily family) => GetPaths().FirstOrDefault(p => p.Source.AddressFamily == family)?.InterfaceName; + + // A hash allows the controller to invalidate old measurements when the physical + // network changes without publishing local addresses or gateway information. + public static PhysicalNetworkState GetNetworkState() + { + var paths = GetPaths(); + if (paths.Count == 0) return new(false, ""); + var key = string.Join('\n', paths.Select(p => p.Key).Distinct().Order(StringComparer.Ordinal)); + return new(true, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))); + } + + public static async Task MeasureAsync(VpnNode node, CancellationToken token = default) + => (await FindPathAsync(node, false, token))?.LatencyMs; + + /// Prefers a physical adapter that reaches this server. If every TCP + /// probe fails, permits a real VPN attempt only when one adapter is available + /// for the server's address family. + /// TCP probes are hints. Multiple unverified adapters cannot be + /// disambiguated by enumeration order; cancellation never selects a fallback. + public static async Task SelectInterfaceAsync(VpnNode node, CancellationToken token = default) + => (await FindPathAsync(node, true, token))?.InterfaceName; + + private static async Task FindPathAsync(VpnNode node, bool allowUniqueUnverifiedAdapter, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + // Never fall back to the system route: while the TUN is active that route + // measures the current VPN rather than direct reachability of this node. + if (!OperatingSystem.IsWindows() || !IPAddress.TryParse(node.Host, out var address) || node.Port is < 1 or > 65535) return null; + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); + if (!IsPublicAddress(address)) return null; + var paths = GetPaths().Where(p => p.Source.AddressFamily == address.AddressFamily).ToArray(); + if (paths.Length == 0) return null; + + using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); + limit.CancelAfter(Timeout); + var remaining = paths.Select(path => ConnectAsync(path, address, node.Port, limit.Token)).ToList(); + try + { + while (remaining.Count > 0) + { + var completed = await Task.WhenAny(remaining); + remaining.Remove(completed); + if (await completed is { } path) + { + token.ThrowIfCancellationRequested(); + return path; + } + } + token.ThrowIfCancellationRequested(); + // A transient timeout must not block an explicit or last-working + // location when the physical adapter is unambiguous. This fallback + // does not turn a failed TCP measurement into a positive latency. + var adapters = paths.Select(path => path.InterfaceName).Distinct(StringComparer.OrdinalIgnoreCase).Take(2).ToArray(); + return allowUniqueUnverifiedAdapter && adapters.Length == 1 ? new(adapters[0], null) : null; + } + finally + { + limit.Cancel(); + // Every socket is disposed before the caller starts another probe. + await Task.WhenAll(remaining); + } + } + + private static async Task ConnectAsync(PhysicalPath path, IPAddress address, int port, CancellationToken token) + { + try + { + token.ThrowIfCancellationRequested(); + using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + var level = address.AddressFamily == AddressFamily.InterNetwork ? SocketOptionLevel.IP : SocketOptionLevel.IPv6; + // Windows requires IPv4 IP_UNICAST_IF in network byte order, but + // IPv6 IPV6_UNICAST_IF in host byte order. Get returns host order. + // https://learn.microsoft.com/windows/win32/winsock/ipproto-ip-socket-options + // https://learn.microsoft.com/windows/win32/winsock/ipproto-ipv6-socket-options + int option = address.AddressFamily == AddressFamily.InterNetwork ? IPAddress.HostToNetworkOrder(path.Index) : path.Index; + socket.SetSocketOption(level, UnicastInterface, option); + socket.Bind(new IPEndPoint(path.Source, 0)); + if (socket.GetSocketOption(level, UnicastInterface) is not int selected || selected != path.Index) return null; + var watch = Stopwatch.StartNew(); + await socket.ConnectAsync(new IPEndPoint(address, port), token); + if (socket.LocalEndPoint is not IPEndPoint local || !local.Address.Equals(path.Source)) return null; + return new(path.InterfaceName, (int)Math.Max(1, watch.ElapsedMilliseconds)); + } + catch (Exception error) when (error is SocketException or OperationCanceledException or NetworkInformationException or ArgumentException) + { + // Timeout/unreachable paths are negative measurements. The public + // caller rethrows cancellation requested by its owner. + return null; + } + } + + private static List GetPaths() + { + var paths = new List(); + if (!OperatingSystem.IsWindows()) return paths; + NetworkInterface[] interfaces; + try { interfaces = NetworkInterface.GetAllNetworkInterfaces(); } + catch (NetworkInformationException) { return paths; } + foreach (var adapter in interfaces) + { + if (!IsPhysicalCandidate(adapter)) continue; + try + { + var properties = adapter.GetIPProperties(); + foreach (var family in new[] { AddressFamily.InterNetwork, AddressFamily.InterNetworkV6 }) + { + var component = family == AddressFamily.InterNetwork ? NetworkInterfaceComponent.IPv4 : NetworkInterfaceComponent.IPv6; + if (!adapter.Supports(component)) continue; + var gateways = properties.GatewayAddresses.Select(g => g.Address) + .Where(g => g.AddressFamily == family && !g.Equals(IPAddress.Any) && !g.Equals(IPAddress.IPv6Any)).ToArray(); + if (gateways.Length == 0) continue; + int index = family == AddressFamily.InterNetwork ? properties.GetIPv4Properties().Index : properties.GetIPv6Properties().Index; + if (index <= 0 || index > 0x00ffffff) continue; + foreach (var unicast in properties.UnicastAddresses) + { + var source = unicast.Address; + if (source.AddressFamily != family || !IsUsableSource(source) + || unicast.DuplicateAddressDetectionState != DuplicateAddressDetectionState.Preferred) continue; + // Temporary IPv6 addresses may rotate without a network + // change; use their network prefix in the cache key. + string sourceKey = family == AddressFamily.InterNetwork ? source.ToString() : Convert.ToHexString(source.GetAddressBytes().AsSpan(0, 8)); + string key = $"{adapter.Id}|{(int)family}|{index}|{sourceKey}|{string.Join(',', gateways.Select(g => g.ToString()).Order(StringComparer.Ordinal))}"; + paths.Add(new(index, source, key, adapter.Name)); + } + } + } + catch (NetworkInformationException) { } + } + return paths; + } + + private static bool IsPhysicalCandidate(NetworkInterface adapter) + { + if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) return false; + // TUN/TAP drivers can report themselves as Ethernet. Only adapters with + // a usable source and a gateway are considered below as well. + string description = adapter.Description; + return !adapter.Name.Equals("QVPN", StringComparison.OrdinalIgnoreCase) + && !adapter.Name.Contains("happ", StringComparison.OrdinalIgnoreCase) + && !description.Contains("happ", StringComparison.OrdinalIgnoreCase) + && !description.Contains("wintun", StringComparison.OrdinalIgnoreCase) + && !description.Contains("sing-tun", StringComparison.OrdinalIgnoreCase) + && !description.Contains("wireguard", StringComparison.OrdinalIgnoreCase) + && !description.Contains("tap-windows", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsUsableSource(IPAddress address) + { + if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return false; + if (address.AddressFamily == AddressFamily.InterNetworkV6) return !address.IsIPv6LinkLocal && !address.IsIPv6Multicast; + var bytes = address.GetAddressBytes(); + return !(bytes[0] == 169 && bytes[1] == 254) && bytes[0] != 0 && bytes[0] < 224; + } + + private static bool IsPublicAddress(IPAddress address) + { + if (!IsUsableSource(address)) return false; + if (address.AddressFamily == AddressFamily.InterNetworkV6) return !address.IsIPv6SiteLocal && (address.GetAddressBytes()[0] & 0xfe) != 0xfc; + var bytes = address.GetAddressBytes(); + return !(bytes[0] == 10 || bytes[0] == 172 && bytes[1] is >= 16 and <= 31 || bytes[0] == 192 && bytes[1] == 168 + || bytes[0] == 100 && bytes[1] is >= 64 and <= 127 || bytes[0] == 198 && bytes[1] is 18 or 19); + } + + private sealed record PathResult(string InterfaceName, int? LatencyMs); + private sealed record PhysicalPath(int Index, IPAddress Source, string Key, string InterfaceName); +} diff --git a/src/QVPN.Core/HealthProbe.cs b/src/QVPN.Core/HealthProbe.cs new file mode 100644 index 0000000..ec89b1a --- /dev/null +++ b/src/QVPN.Core/HealthProbe.cs @@ -0,0 +1,135 @@ +using System.Net; + +namespace QVPN.Core; + +public sealed record HealthProbeResult(bool ProxyAvailable, bool? SystemAvailable) +{ + public bool Healthy => ProxyAvailable && SystemAvailable != false; + public bool SystemPathFailed => ProxyAvailable && SystemAvailable == false; +} + +/// Checks new VPN connections and, in TUN mode, the Windows DNS/HTTPS path. +public sealed class HealthProbe : IDisposable +{ + private static readonly Uri[] DefaultEndpoints = + [ + new("https://cp.cloudflare.com/generate_204"), + new("https://www.gstatic.com/generate_204"), + new("https://www.google.com/generate_204") + ]; + private static readonly TimeSpan DefaultBudget = TimeSpan.FromSeconds(6); + private readonly HttpClient http; + private readonly HttpClient? systemHttp; + private readonly Uri[] endpoints; + private readonly TimeSpan budget; + private readonly bool ownsClients; + + public HealthProbe() : this(CreateClient(true), null, null, CreateClient(false)) => ownsClients = true; + + /// The injected client remains owned by its caller; useful for deterministic transport tests. + public HealthProbe(HttpClient http, IEnumerable? endpoints = null, TimeSpan? budget = null, HttpClient? systemHttp = null) + { + ArgumentNullException.ThrowIfNull(http); + this.http = http; + this.systemHttp = systemHttp; + this.endpoints = (endpoints ?? DefaultEndpoints).ToArray(); + if (this.endpoints.Length == 0 || this.endpoints.Any(uri => uri is null || !uri.IsAbsoluteUri || uri.Scheme != Uri.UriSchemeHttps)) + throw new ArgumentException("Health endpoints must be absolute HTTPS URLs.", nameof(endpoints)); + this.budget = budget ?? DefaultBudget; + if (this.budget <= TimeSpan.Zero || this.budget.TotalMilliseconds > uint.MaxValue - 1) + throw new ArgumentOutOfRangeException(nameof(budget)); + } + + private static HttpClient CreateClient(bool proxy) => new(new SocketsHttpHandler + { + Proxy = proxy ? new WebProxy("http://127.0.0.1:" + Constants.ProxyPort) : null, + UseProxy = proxy, + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.Zero + }) { Timeout = Timeout.InfiniteTimeSpan }; + + public async Task CheckAsync(CancellationToken token = default) => + (await CheckPathsAsync(false, token)).Healthy; + + public async Task CheckPathsAsync(bool requireSystemPath, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (requireSystemPath && systemHttp is null) throw new InvalidOperationException("A system HTTP client is required for TUN health checks."); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(token); + stop.CancelAfter(budget); + // Both paths share one deadline; success on the explicit proxy must not + // cancel the independent Windows DNS/routing check. + var proxy = CheckPathAsync(http, stop.Token); + var system = requireSystemPath ? CheckPathAsync(systemHttp!, stop.Token) : Task.FromResult(true); + var completion = Task.WhenAll(proxy, system); + try { await completion.WaitAsync(stop.Token); } + catch (OperationCanceledException) { token.ThrowIfCancellationRequested(); } + finally + { + stop.Cancel(); + _ = ObserveCompletionAsync(completion); + } + token.ThrowIfCancellationRequested(); + return new(proxy.IsCompletedSuccessfully && proxy.Result, + requireSystemPath ? system.IsCompletedSuccessfully && system.Result : null); + } + + private async Task CheckPathAsync(HttpClient client, CancellationToken token) + { + using var stop = CancellationTokenSource.CreateLinkedTokenSource(token); + var probes = endpoints.Select(endpoint => ProbeAsync(client, endpoint, stop.Token)).ToArray(); + var pending = probes.ToList(); + try + { + while (pending.Count > 0) + { + // Bound the whole check even if a transport is slow to honor cancellation. + var completed = await Task.WhenAny(pending).WaitAsync(stop.Token); + pending.Remove(completed); + stop.Token.ThrowIfCancellationRequested(); + if (await completed) return true; + } + return false; + } + catch (OperationCanceledException) + { + return false; + } + finally + { + stop.Cancel(); // A successful endpoint immediately cancels its pending peers. + _ = ObserveCompletionAsync(Task.WhenAll(probes)); + } + } + + private static async Task ProbeAsync(HttpClient client, Uri endpoint, CancellationToken token) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint) + { + Version = HttpVersion.Version11, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + // A pooled HTTPS connection can survive credential rotation even + // when every new VPN connection fails. Always test a new session. + request.Headers.ConnectionClose = true; + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + return response.StatusCode == HttpStatusCode.NoContent; + } + catch (HttpRequestException) { return false; } + catch (OperationCanceledException) { return false; } + } + + private static async Task ObserveCompletionAsync(Task completion) + { + // Do not await a losing transport on the caller's path, but observe its completion. + try { await completion; } + catch { } + } + + public void Dispose() + { + if (ownsClients) { http.Dispose(); systemHttp?.Dispose(); } + } +} diff --git a/src/QVPN.Core/Models.cs b/src/QVPN.Core/Models.cs index 0b9fd4b..a09e1b5 100644 --- a/src/QVPN.Core/Models.cs +++ b/src/QVPN.Core/Models.cs @@ -9,6 +9,7 @@ public static class Constants public const string ServiceName = "QVPNService"; public const string PipeName = "QVPN.Control.v1"; public const int ProxyPort = 17891; + public const int XrayPort = 17892; public static string DataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN"); public static JsonSerializerOptions Json { get; } = new() { PropertyNameCaseInsensitive = true, WriteIndented = false }; } @@ -22,12 +23,14 @@ public sealed class Settings public bool RussianDirect { get; set; } = true; public bool AutoFailover { get; set; } = true; public string SelectedId { get; set; } = ""; + public string LastWorkingId { get; set; } = ""; public DateTimeOffset? UpdatedAt { get; set; } public string? SubscriptionInfo { get; set; } } public record Request(string Command, string? Value = null, bool? Enabled = null); public record Status(string State, string Message, bool DesiredConnected, string? ActiveId, string? ActiveName, DateTimeOffset? ConnectedSince, - int Restarts, int? EnginePid, bool RussianDirect, bool AutoFailover, string SelectedId, DateTimeOffset? UpdatedAt, List Nodes, List Events); + int Restarts, int? EnginePid, bool RussianDirect, bool AutoFailover, string SelectedId, DateTimeOffset? UpdatedAt, List Nodes, List Events, + int? XrayPid = null, string? VpnCore = null, bool? SystemRoutesVerified = null); public record Response(bool Ok, string? Error, Status? Status); public sealed class SecureStore(string directory) diff --git a/src/QVPN.Core/NodeSelection.cs b/src/QVPN.Core/NodeSelection.cs new file mode 100644 index 0000000..5e76d89 --- /dev/null +++ b/src/QVPN.Core/NodeSelection.cs @@ -0,0 +1,82 @@ +using System.Text; + +namespace QVPN.Core; + +public static class NodeSelection +{ + /// Maps a saved location to a refreshed subscription without guessing between duplicate locations. + public static VpnNode? FindReplacement(VpnNode oldNode, IReadOnlyList newNodes) + { + ArgumentNullException.ThrowIfNull(oldNode); + ArgumentNullException.ThrowIfNull(newNodes); + var sameId = newNodes.FirstOrDefault(node => node.Id == oldNode.Id); + if (sameId is not null) return sameId; + + string oldName = NormalizeLocationName(oldNode.Name); + if (oldName.Length > 0) + { + var sameName = newNodes.Where(node => string.Equals(NormalizeLocationName(node.Name), oldName, + StringComparison.OrdinalIgnoreCase)).Take(2).ToArray(); + if (sameName.Length == 1) return sameName[0]; + } + + // IDs now include flow and fingerprint. Preserve the exact transport + // when an older saved ID migrates and several variants share a location. + var sameConnection = newNodes.Where(node => ConnectionEquals(oldNode, node)).Take(2).ToArray(); + if (sameConnection.Length == 1) return sameConnection[0]; + + var sameEndpoint = newNodes.Where(node => node.Port == oldNode.Port + && string.Equals(node.Host, oldNode.Host, StringComparison.OrdinalIgnoreCase)).Take(2).ToArray(); + return sameEndpoint.Length == 1 ? sameEndpoint[0] : null; + } + + private static string NormalizeLocationName(string name) => string.Join(" ", + name.Normalize(NormalizationForm.FormKC).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + public static HashSet RemapFailedConnections(IReadOnlyList oldNodes, IReadOnlyList newNodes, + IReadOnlySet failed) + { + var oldFailures = oldNodes.Where(node => failed.Contains(node.Id)).ToArray(); + return newNodes.Where(node => oldFailures.Any(old => ConnectionEquals(old, node))).Select(node => node.Id).ToHashSet(StringComparer.Ordinal); + } + + public static VpnNode? Choose(IReadOnlyList nodes, string selectedId, bool autoFailover, + string? lastWorkingId, IReadOnlySet failed, IReadOnlyDictionary latencies) + { + var selected = nodes.FirstOrDefault(n => n.Id == selectedId); + // A user-selected location stays selected while automatic switching is disabled. + if (!autoFailover) return selected ?? nodes.FirstOrDefault(n => n.Id == lastWorkingId) ?? nodes.FirstOrDefault(); + + var candidates = nodes.Where(n => !failed.Contains(n.Id)).ToList(); + // Exhaustion is handled by the controller's recovery delay, not by silently + // picking an already failed node on every tick. + if (candidates.Count == 0) return null; + + // TCP measurements are hints. An explicit user choice still gets a VPN + // attempt because a failed TCP measurement does not prove VPN failure. + if (selected is not null && !failed.Contains(selected.Id)) return selected; + + // A one-off TCP timeout does not invalidate a previously verified VPN. + // Only failed HTTPS tunnel checks should move us away from it. + var lastWorking = candidates.FirstOrDefault(n => n.Id == lastWorkingId); + if (lastWorking is not null) return lastWorking; + + var available = candidates.Where(n => !latencies.TryGetValue(n.Id, out var ms) || ms.HasValue).ToList(); + // If every TCP probe failed, allow a real VPN attempt instead of deadlocking. + if (available.Count == 0) available = candidates; + + // Reachable nodes rank ahead of unmeasured nodes, with stable subscription + // ordering for ties. Missing measurements differ from an explicit failure. + return available.OrderBy(n => latencies.TryGetValue(n.Id, out var ms) && ms.HasValue ? 0 : 1) + .ThenBy(n => latencies.TryGetValue(n.Id, out var ms) && ms.HasValue ? ms.Value : int.MaxValue) + .First(); + } + + public static bool ConnectionEquals(VpnNode? left, VpnNode? right) + { + if (left is null || right is null) return left is null && right is null; + return left.Host == right.Host && left.Port == right.Port && left.Uuid == right.Uuid + && left.ServerName == right.ServerName && left.PublicKey == right.PublicKey + && left.ShortId == right.ShortId && left.Fingerprint == right.Fingerprint && left.Flow == right.Flow; + } +} diff --git a/src/QVPN.Core/RecoveryPolicy.cs b/src/QVPN.Core/RecoveryPolicy.cs index 9ebe33b..bbf64c9 100644 --- a/src/QVPN.Core/RecoveryPolicy.cs +++ b/src/QVPN.Core/RecoveryPolicy.cs @@ -5,10 +5,16 @@ public sealed class RecoveryPolicy public int ConsecutiveFailures { get; private set; } public int Attempts { get; private set; } public DateTimeOffset NextAttempt { get; private set; } - public bool HealthFailed() => ++ConsecutiveFailures >= 3; + public bool HealthFailed(bool established = true) => ++ConsecutiveFailures >= (established ? 3 : 2); public void Healthy() { ConsecutiveFailures = 0; } public void Stable() { Healthy(); Attempts = 0; } - public void Failed(DateTimeOffset now) { ConsecutiveFailures = 0; Attempts = Math.Min(Attempts + 1, 10); NextAttempt = now.AddSeconds(Math.Min(60, 2 * Math.Pow(2, Attempts - 1))); } + public void Failed(DateTimeOffset now, bool alternativeAvailable = false) + { + ConsecutiveFailures = 0; + if (alternativeAvailable) { NextAttempt = now.AddMilliseconds(250); return; } + Attempts = Math.Min(Attempts + 1, 10); + NextAttempt = now.AddSeconds(Math.Min(60, 2 * Math.Pow(2, Attempts - 1))); + } public void Reset() { Stable(); NextAttempt = DateTimeOffset.MinValue; } public bool CanAttempt(DateTimeOffset now) => now >= NextAttempt; } diff --git a/src/QVPN.Core/Subscription.cs b/src/QVPN.Core/Subscription.cs index c6caa12..39dbda4 100644 --- a/src/QVPN.Core/Subscription.cs +++ b/src/QVPN.Core/Subscription.cs @@ -1,8 +1,11 @@ using System.Net; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; +[assembly: InternalsVisibleTo("QVPN.Tests")] + namespace QVPN.Core; public static partial class Subscription @@ -31,8 +34,38 @@ public static partial class Subscription { var uri = ValidateUrl(url); using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) }; + using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; http.DefaultRequestHeaders.UserAgent.ParseAdd("v2rayN/7.0 QVPN/1.0"); + return await FetchWithClientAsync(uri.AbsoluteUri, http, TimeSpan.FromSeconds(12), token); + } + + // Test injection remains internal: every request still validates the provider + // HTTPS URL, and production clients retain strict certificate validation. + internal static async Task<(List Nodes, string? Info)> FetchWithClientAsync( + string url, HttpClient http, TimeSpan budget, CancellationToken token) + { + var uri = ValidateUrl(url); + token.ThrowIfCancellationRequested(); + if (budget <= TimeSpan.Zero || budget.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(budget)); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token); + deadline.CancelAfter(budget); + try + { + return await ReadResponseAsync(uri, http, deadline.Token); + } + catch (OperationCanceledException) + { + // Preserve the original caller token, including the controller's + // shorter startup deadline. Only our own timeout may trigger retry. + token.ThrowIfCancellationRequested(); + throw; + } + } + + private static async Task<(List Nodes, string? Info)> ReadResponseAsync(Uri uri, HttpClient http, CancellationToken token) + { + // ResponseHeadersRead ends HttpClient's timeout at the headers. This + // same deadline must therefore also cover every streamed body read. using var response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new InvalidDataException("BlancVPN отклонил ключ. Проверьте подписку."); if (response.StatusCode == HttpStatusCode.NotFound) throw new InvalidDataException("Ключ BlancVPN не найден."); @@ -41,6 +74,7 @@ public static partial class Subscription using var buffer = new MemoryStream(); byte[] chunk = new byte[8192]; int n; while ((n = await stream.ReadAsync(chunk, token)) != 0) { if (buffer.Length + n > 2 * 1024 * 1024) throw new InvalidDataException("Подписка слишком большая."); buffer.Write(chunk, 0, n); } + token.ThrowIfCancellationRequested(); return (Parse(Encoding.UTF8.GetString(buffer.ToArray())), response.Headers.TryGetValues("subscription-userinfo", out var info) ? info.FirstOrDefault() : null); } public static List Parse(string text) @@ -65,8 +99,10 @@ public static partial class Subscription if (!IPAddress.TryParse(host, out var ip) || !PublicIp(ip)) throw new InvalidDataException("Ожидается публичный IP-адрес локации BlancVPN."); var pub = Get("pbk"); var sid = Get("sid"); var sni = Get("sni"); var flow = Get("flow"); var fp = Get("fp", "chrome"); if (!Regex.IsMatch(pub, @"^[A-Za-z0-9_-]{43}$") || !Regex.IsMatch(sid, @"^(?:[0-9a-fA-F]{2}){0,8}$") || Uri.CheckHostName(sni) != UriHostNameType.Dns || sni.Length > 253 - || !(flow is "" or "xtls-rprx-vision") || !new[] { "chrome", "firefox", "safari", "edge", "ios", "android", "random", "randomized" }.Contains(fp)) throw new InvalidDataException("Некорректные параметры Reality в подписке."); - string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{uuid}|{host}|{u.Port}|{sni}|{pub}|{sid}")))[..20]; + || !(flow is "" or "xtls-rprx-vision" or "xtls-rprx-vision-udp443") || !new[] { "chrome", "firefox", "safari", "edge", "ios", "android", "random", "randomized" }.Contains(fp)) throw new InvalidDataException("Некорректные параметры Reality в подписке."); + // Distinct transport variants of an endpoint must remain selectable. + // Presentation-only changes still deduplicate to the same identity. + string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{uuid}|{host}|{u.Port}|{sni}|{pub}|{sid}|{fp}|{flow}")))[..20]; string name = Uri.UnescapeDataString(u.Fragment.TrimStart('#')); name = new string(name.Where(c => !char.IsControl(c)).Take(160).ToArray()); if (name.Length == 0) name = "Локация " + (nodes.Count + 1); diff --git a/src/QVPN.Core/SubscriptionRefreshPolicy.cs b/src/QVPN.Core/SubscriptionRefreshPolicy.cs new file mode 100644 index 0000000..9163b48 --- /dev/null +++ b/src/QVPN.Core/SubscriptionRefreshPolicy.cs @@ -0,0 +1,18 @@ +namespace QVPN.Core; + +public sealed class SubscriptionRefreshPolicy +{ + public static readonly TimeSpan StartupCacheLifetime = TimeSpan.FromMinutes(15); + public static readonly TimeSpan StartupRefreshBudget = TimeSpan.FromSeconds(5); + public static readonly TimeSpan AttemptInterval = TimeSpan.FromSeconds(60); + public DateTimeOffset? LastAttempt { get; private set; } + + public bool CanAttempt(DateTimeOffset now) => LastAttempt is null || now - LastAttempt.Value >= AttemptInterval; + + public bool ShouldRefreshOnConnect(DateTimeOffset? cachedAt, DateTimeOffset now, bool explicitConnect = false) => + CanAttempt(now) && (explicitConnect || cachedAt is null || now - cachedAt.Value >= StartupCacheLifetime); + + public bool ShouldRefreshAfterEndpointFailure(DateTimeOffset now) => CanAttempt(now); + + public void Attempted(DateTimeOffset now) => LastAttempt = now; +} diff --git a/src/QVPN.Core/SystemTunnelProbe.cs b/src/QVPN.Core/SystemTunnelProbe.cs new file mode 100644 index 0000000..58570a5 --- /dev/null +++ b/src/QVPN.Core/SystemTunnelProbe.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace QVPN.Core; + +public sealed record SystemTunnelState(bool AdapterAvailable, bool Ipv4RoutedThroughTunnel, bool? Ipv6RoutedThroughTunnel) +{ + public bool Healthy => AdapterAvailable && Ipv4RoutedThroughTunnel && Ipv6RoutedThroughTunnel != false; +} + +/// Verifies that Windows still routes public control destinations into QVPN. +public static class SystemTunnelProbe +{ + private static readonly IPAddress Ipv4Control = IPAddress.Parse("1.1.1.1"); + private static readonly IPAddress Ipv6Control = IPAddress.Parse("2606:4700:4700::1111"); + + public static SystemTunnelState Check() + { + if (!OperatingSystem.IsWindows()) return new(false, false, null); + try + { + var adapter = NetworkInterface.GetAllNetworkInterfaces().FirstOrDefault(n => + n.OperationalStatus == OperationalStatus.Up && n.Name.Equals("QVPN", StringComparison.OrdinalIgnoreCase)); + if (adapter is null) return new(false, false, null); + var properties = adapter.GetIPProperties(); + int ipv4 = adapter.Supports(NetworkInterfaceComponent.IPv4) ? properties.GetIPv4Properties().Index : 0; + int ipv6 = adapter.Supports(NetworkInterfaceComponent.IPv6) ? properties.GetIPv6Properties().Index : 0; + return Evaluate(true, ipv4, ipv6, Lookup(Ipv4Control), Socket.OSSupportsIPv6 ? Lookup(Ipv6Control) : null); + } + catch (Exception error) when (error is NetworkInformationException or SocketException or DllNotFoundException or EntryPointNotFoundException) + { + return new(false, false, false); + } + } + + internal static SystemTunnelState Evaluate(bool adapterAvailable, int ipv4Index, int ipv6Index, + RouteLookup ipv4, RouteLookup? ipv6) + { + bool throughIpv4 = ipv4Index > 0 && ipv4.Error == 0 && ipv4.InterfaceIndex == (uint)ipv4Index; + bool? throughIpv6 = ipv6 is null || HasNoRoute(ipv6.Value.Error) ? null + : ipv6Index > 0 && ipv6.Value.Error == 0 && ipv6.Value.InterfaceIndex == (uint)ipv6Index; + return new(adapterAvailable, throughIpv4, throughIpv6); + } + + // No IPv6 route means that family cannot bypass the tunnel. An API error + // of unknown origin is not evidence of an absent route and fails the check. + private static bool HasNoRoute(uint error) => error is 1168 or 1231; + + private static RouteLookup Lookup(IPAddress destination) + { + SocketAddress socketAddress = new IPEndPoint(destination, 0).Serialize(); + byte[] native = new byte[socketAddress.Size]; + for (int i = 0; i < native.Length; i++) native[i] = socketAddress[i]; + uint error = GetBestInterfaceEx(native, out uint index); + return new(error, index); + } + + internal readonly record struct RouteLookup(uint Error, uint InterfaceIndex); + + [DllImport("iphlpapi.dll", ExactSpelling = true)] + private static extern uint GetBestInterfaceEx([In] byte[] destinationAddress, out uint bestInterfaceIndex); +} diff --git a/src/QVPN.Core/XrayConfigBuilder.cs b/src/QVPN.Core/XrayConfigBuilder.cs new file mode 100644 index 0000000..9242983 --- /dev/null +++ b/src/QVPN.Core/XrayConfigBuilder.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Nodes; + +namespace QVPN.Core; + +public static class XrayConfigBuilder +{ + // Xray 26.9.9: infra/conf/vless.go, transport_internet.go and + // transport/internet/sockopt_windows.go. Only the VLESS/Reality transport + // runs here; the local routing bridge owns TUN, DNS and direct-site rules. + public static JsonObject Build(VpnNode node, int port = Constants.XrayPort, string? bindInterface = null) + { + ArgumentNullException.ThrowIfNull(node); + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(port)); + if (bindInterface is not null && (string.IsNullOrWhiteSpace(bindInterface) || bindInterface.Length > 256 || bindInterface.Any(char.IsControl))) + throw new ArgumentException("Некорректный сетевой интерфейс Xray.", nameof(bindInterface)); + + var stream = new JsonObject + { + ["network"] = "raw", + ["security"] = "reality", + ["realitySettings"] = new JsonObject + { + ["show"] = false, + ["fingerprint"] = node.Fingerprint, + ["serverName"] = node.ServerName, + // This tag supports both publicKey and its newer password name. + ["password"] = node.PublicKey, + ["shortId"] = node.ShortId, + ["spiderX"] = "/" + } + }; + if (bindInterface is not null) + stream["sockopt"] = new JsonObject { ["interface"] = bindInterface }; + + return new JsonObject + { + ["log"] = new JsonObject { ["loglevel"] = "error" }, + ["inbounds"] = new JsonArray(new JsonObject + { + ["tag"] = "bridge-in", ["listen"] = "127.0.0.1", ["port"] = port, ["protocol"] = "socks", + ["settings"] = new JsonObject { ["auth"] = "noauth", ["udp"] = true, ["ip"] = "127.0.0.1" }, + ["sniffing"] = new JsonObject { ["enabled"] = false } + }), + ["outbounds"] = new JsonArray(new JsonObject + { + ["tag"] = "proxy", ["protocol"] = "vless", + ["settings"] = new JsonObject + { + ["vnext"] = new JsonArray(new JsonObject + { + ["address"] = node.Host, ["port"] = node.Port, + ["users"] = new JsonArray(new JsonObject + { + ["id"] = node.Uuid, ["encryption"] = "none", ["flow"] = node.Flow + }) + }) + }, + ["streamSettings"] = stream, + ["mux"] = new JsonObject { ["enabled"] = false } + }), + ["routing"] = new JsonObject + { + ["domainStrategy"] = "AsIs", + ["rules"] = new JsonArray(new JsonObject + { + ["type"] = "field", ["inboundTag"] = new JsonArray("bridge-in"), ["outboundTag"] = "proxy" + }) + } + }; + } +} diff --git a/src/QVPN.Service/Program.cs b/src/QVPN.Service/Program.cs index 0ea3290..8e4bb68 100644 --- a/src/QVPN.Service/Program.cs +++ b/src/QVPN.Service/Program.cs @@ -11,7 +11,7 @@ if (args.Contains("--console")) Directory.CreateDirectory(path); using var stop = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); }; - await using var controller = new VpnController(path, args.Contains("--socks-only"), Arg("--core")); + await using var controller = new VpnController(path, args.Contains("--socks-only"), Arg("--core"), Arg("--xray")); var pipe = new ControlServer(controller, WindowsIdentity.GetCurrent().User!.Value); Console.WriteLine("QVPN diagnostic console. " + (args.Contains("--socks-only") ? "SOCKS test only; system routing unchanged." : "TUN enabled.")); try diff --git a/src/QVPN.Service/VpnController.cs b/src/QVPN.Service/VpnController.cs index 905e423..5dfdb7b 100644 --- a/src/QVPN.Service/VpnController.cs +++ b/src/QVPN.Service/VpnController.cs @@ -1,8 +1,8 @@ using QVPN.Core; using System.Diagnostics; using System.Net; -using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Net.NetworkInformation; using System.Text.Json; namespace QVPN.Service; @@ -11,30 +11,44 @@ public sealed class VpnController : IAsyncDisposable { private readonly SemaphoreSlim gate = new(1); private readonly SecureStore store; - private readonly string directory, corePath, rulesPath; + private readonly string directory, corePath, xrayPath, rulesPath; private readonly bool socksOnly; private Settings settings; private readonly RecoveryPolicy recovery = new(); + private readonly SubscriptionRefreshPolicy subscriptionRefresh = new(); + private readonly HealthProbe healthProbe = new(); + private readonly SemaphoreSlim wake = new(0, 1); private readonly List events = []; private readonly Dictionary latencies = []; private Process? engine; + private Process? xray; private ProcessJob? job; private VpnNode? active; private string state = "disconnected", message = "VPN отключён"; - private DateTimeOffset? connectedSince, startedAt; + private DateTimeOffset? connectedSince, healthySince; private DateTimeOffset healthDue, refreshDue = DateTimeOffset.UtcNow.AddHours(6); - private int restarts, resumeRequested, measuring; + private int restarts, resumeRequested, measuring, measurementRequested; private Status snapshot = new("disconnected", "Служба запускается", false, null, null, null, 0, null, true, true, "", null, [], []); private readonly HashSet failed = []; private Task? measureTask; + private Task? healthTask; + private CancellationTokenSource? healthStop; + private Task<(List Nodes, string? Info)>? refreshTask; + private CancellationTokenSource? refreshStop; + private string? refreshUrl, networkPath; + private int subscriptionGeneration, refreshGeneration, manualRefreshSequence, refreshManualSequence; + private bool waitingNetwork, connectionRefreshPending = true, explicitConnectionRefreshPending, refreshForRecovery, refreshBeforeStart; private readonly CancellationTokenSource measurementStop = new(); private bool disposed; + private bool? systemRoutesVerified; private string RuntimePath => Path.Combine(directory, "runtime.json"); + private string XrayRuntimePath => Path.Combine(directory, "runtime-xray.json"); - public VpnController(string directory, bool socksOnly, string? overrideCore) + public VpnController(string directory, bool socksOnly, string? overrideCore, string? overrideXray = null) { this.directory = directory; this.socksOnly = socksOnly; store = new(directory); corePath = overrideCore ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "core", "sing-box.exe")); + xrayPath = overrideXray ?? Path.Combine(Path.GetDirectoryName(corePath)!, "xray.exe"); rulesPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(corePath)!, "..", "rules")); try { settings = store.Load(); } catch { settings = new(); Event("Не удалось прочитать настройки. Добавьте ключ заново; исходный файл сохранён."); } @@ -46,7 +60,8 @@ public sealed class VpnController : IAsyncDisposable Event("Служба QVPN запущена" + (socksOnly ? " (диагностика SOCKS)" : "")); Publish(); } public Status GetStatus() => Volatile.Read(ref snapshot); - public void Resume() => Interlocked.Exchange(ref resumeRequested, 1); + public void Resume() { Interlocked.Exchange(ref resumeRequested, 1); Wake(); } + private void Wake() { try { wake.Release(); } catch (SemaphoreFullException) { } } private void Event(string text) { lock (events) @@ -68,70 +83,103 @@ public sealed class VpnController : IAsyncDisposable List log; lock (events) log = events.TakeLast(60).ToList(); Volatile.Write(ref snapshot, new(state, message, settings.DesiredConnected, active?.Id, active?.Name, connectedSince, restarts, engine is { HasExited: false } ? engine.Id : null, settings.RussianDirect, settings.AutoFailover, settings.SelectedId, settings.UpdatedAt, - settings.Nodes.Select(n => new NodeView(n.Id, n.Name, latencies.GetValueOrDefault(n.Id))).ToList(), log)); + settings.Nodes.Select(n => new NodeView(n.Id, n.Name, latencies.GetValueOrDefault(n.Id))).ToList(), log, + xray is { HasExited: false } ? xray.Id : null, "Xray", systemRoutesVerified)); } public async Task ExecuteAsync(Request request, CancellationToken token) { if (request.Command == "status") return new(true, null, GetStatus()); + // Download outside the controller lock: a slow subscription must not block + // disconnect, failover, or detection of a dead engine. + if (request.Command is "import" or "refresh") + { + string url = request.Command == "import" ? request.Value ?? throw new ArgumentException("Введите ссылку подписки.") : settings.SubscriptionUrl; + if (url.Length == 0) throw new ArgumentException("Сначала добавьте ключ BlancVPN."); + url = Subscription.ValidateUrl(url).AbsoluteUri; + int sequence = Interlocked.Increment(ref manualRefreshSequence); + await gate.WaitAsync(token); + try { subscriptionRefresh.Attempted(DateTimeOffset.UtcNow); } + finally { gate.Release(); } + var result = await Subscription.DownloadAsync(url, token); + await gate.WaitAsync(token); + try + { + if (sequence != Volatile.Read(ref manualRefreshSequence)) return new(false, "Уже выполняется более новое обновление подписки.", GetStatus()); + await ApplySubscriptionAsync(url, result); + if (request.Command == "import") { failed.Clear(); recovery.Reset(); } + Publish(); return new(true, null, GetStatus()); + } + finally { Publish(); gate.Release(); Wake(); } + } await gate.WaitAsync(token); try { switch (request.Command) { - case "import": - if (request.Value == null) throw new ArgumentException("Введите ссылку подписки."); - await RefreshAsync(Subscription.ValidateUrl(request.Value).AbsoluteUri, token); failed.Clear(); recovery.Reset(); - break; - case "refresh": - if (settings.SubscriptionUrl.Length == 0) throw new ArgumentException("Сначала добавьте ключ BlancVPN."); - await RefreshAsync(settings.SubscriptionUrl, token); break; case "connect": if (settings.Nodes.Count == 0) throw new ArgumentException("Сначала добавьте ключ BlancVPN."); if (!socksOnly && HasOtherVpn()) throw new ArgumentException("Отключите HAPP перед подключением QVPN. Одновременные VPN конфликтуют."); - Persist(s => s.DesiredConnected = true); failed.Clear(); recovery.Reset(); State("connecting", "Подключаемся…"); break; + if (settings.DesiredConnected && engine is { HasExited: false }) break; + Persist(s => s.DesiredConnected = true); failed.Clear(); recovery.Reset(); BeginMeasurements(); + connectionRefreshPending = true; explicitConnectionRefreshPending = true; + if (engine == null) State("connecting", "Подключаемся…"); break; case "disconnect": settings.DesiredConnected = false; + connectionRefreshPending = false; explicitConnectionRefreshPending = false; try { store.Save(settings); } finally { await StopEngineAsync(); recovery.Reset(); failed.Clear(); State("disconnected", "VPN отключён"); } break; case "select": if (!string.IsNullOrEmpty(request.Value) && settings.Nodes.All(n => n.Id != request.Value)) throw new ArgumentException("Локация не найдена. Обновите подписку."); + if ((request.Value ?? "") == settings.SelectedId) break; Persist(s => s.SelectedId = request.Value ?? ""); failed.Clear(); recovery.Reset(); await StopEngineAsync(); if (settings.DesiredConnected) State("connecting", "Подключаемся к выбранной локации…"); break; case "russian": + if ((request.Enabled ?? true) == settings.RussianDirect) break; Persist(s => s.RussianDirect = request.Enabled ?? true); await StopEngineAsync(); recovery.Reset(); if (settings.DesiredConnected) State("connecting", "Применяем маршрутизацию…"); break; case "failover": Persist(s => s.AutoFailover = request.Enabled ?? true); break; case "measure": - if (Interlocked.CompareExchange(ref measuring, 1, 0) == 0) - { - var nodes = settings.Nodes.ToArray(); measureTask = Task.Run(() => MeasureAsync(nodes)); Event("Измеряем задержку локаций…"); - } + BeginMeasurements(); break; default: throw new ArgumentException("Неизвестная команда QVPN."); } Publish(); return new(true, null, GetStatus()); } - finally { Publish(); gate.Release(); } + finally { Publish(); gate.Release(); Wake(); } } private void Persist(Action change) { var updated = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings, Constants.Json), Constants.Json)!; change(updated); store.Save(updated); settings = updated; } - private async Task RefreshAsync(string url, CancellationToken token) + private async Task ApplySubscriptionAsync(string url, (List Nodes, string? Info) result) { - var result = await Subscription.DownloadAsync(url, token); // Commit only a complete, validated subscription. Keep the connected process if its credential is unchanged. var old = active; + var oldSelected = settings.Nodes.FirstOrDefault(n => n.Id == settings.SelectedId); + var oldWorking = settings.Nodes.FirstOrDefault(n => n.Id == settings.LastWorkingId); + var selectedReplacement = oldSelected == null ? null : NodeSelection.FindReplacement(oldSelected, result.Nodes); + var workingReplacement = oldWorking == null ? null : NodeSelection.FindReplacement(oldWorking, result.Nodes); + var retainedFailures = NodeSelection.RemapFailedConnections(settings.Nodes, result.Nodes, failed); Persist(s => { s.SubscriptionUrl = url; s.Nodes = result.Nodes; s.SubscriptionInfo = result.Info; s.UpdatedAt = DateTimeOffset.UtcNow; - if (s.SelectedId.Length > 0 && s.Nodes.All(n => n.Id != s.SelectedId)) s.SelectedId = ""; + s.SelectedId = selectedReplacement?.Id ?? ""; + s.LastWorkingId = workingReplacement?.Id ?? ""; }); + subscriptionGeneration++; + failed.Clear(); failed.UnionWith(retainedFailures); + foreach (var id in latencies.Keys.Where(id => settings.Nodes.All(n => n.Id != id)).ToArray()) latencies.Remove(id); + BeginMeasurements(); refreshDue = DateTimeOffset.UtcNow.AddHours(6); Event("Подписка обновлена: " + result.Nodes.Count + " локаций."); - if (old != null && !settings.Nodes.Contains(old)) { await StopEngineAsync(); if (settings.DesiredConnected) State("connecting", "Обновлены параметры локации…"); } + if (old != null) + { + var updated = settings.Nodes.FirstOrDefault(n => NodeSelection.ConnectionEquals(old, n)); + if (updated != null) active = updated; + else { await StopEngineAsync(); recovery.Reset(); if (settings.DesiredConnected) State("connecting", "Обновлены параметры локации…"); } + } } public async Task RunAsync(CancellationToken token) { @@ -148,7 +196,7 @@ public sealed class VpnController : IAsyncDisposable await StopEngineAsync(); recovery.Failed(DateTimeOffset.UtcNow); State("recovering", SafeError.Describe(e) + " Повторяем подключение автоматически."); } finally { Publish(); gate.Release(); } - await Task.Delay(TimeSpan.FromSeconds(3), token); + await wake.WaitAsync(TimeSpan.FromSeconds(1), token); } } finally { await gate.WaitAsync(); try { await StopEngineAsync(); } finally { gate.Release(); } } @@ -156,135 +204,287 @@ public sealed class VpnController : IAsyncDisposable private async Task TickAsync(CancellationToken token) { var now = DateTimeOffset.UtcNow; + await CompleteBackgroundRefreshAsync(token); if (!settings.DesiredConnected) return; - if (!PhysicalNetworkAvailable()) { await StopEngineAsync(); State("waiting-network", "Ожидаем доступную сеть. Подключение восстановится автоматически."); return; } - if (!socksOnly && HasOtherVpn()) { await StopEngineAsync(); State("waiting-network", "Отключите HAPP: QVPN ожидает освобождения VPN-маршрута."); return; } - if (Interlocked.Exchange(ref resumeRequested, 0) != 0) { await StopEngineAsync(); recovery.Reset(); State("recovering", "Восстанавливаем VPN после сна…"); } - if (now >= refreshDue && settings.SubscriptionUrl.Length > 0) + var network = DirectLatencyProbe.GetNetworkState(); + if (!network.Available) { waitingNetwork = true; await StopEngineAsync(); State("waiting-network", "Ожидаем доступную сеть. Подключение восстановится автоматически."); return; } + if (!socksOnly && HasOtherVpn()) { waitingNetwork = true; await StopEngineAsync(); State("waiting-network", "Отключите HAPP: QVPN ожидает освобождения VPN-маршрута."); return; } + bool resumed = Interlocked.Exchange(ref resumeRequested, 0) != 0; + if (waitingNetwork || networkPath != network.PathKey || resumed) { - try { await RefreshAsync(settings.SubscriptionUrl, token); } - catch (Exception e) when (e is not OperationCanceledException || !token.IsCancellationRequested) { Event(SafeError.Describe(e)); refreshDue = now.AddMinutes(15); } + bool changed = networkPath != null; + networkPath = network.PathKey; waitingNetwork = false; recovery.Reset(); failed.Clear(); latencies.Clear(); + if (changed) { await StopEngineAsync(); State("recovering", "Восстанавливаем VPN после изменения сети или сна…"); } + BeginMeasurements(); } - if (engine is { HasExited: true }) + if (connectionRefreshPending) { - Event("VPN-процесс завершился. Код: " + engine.ExitCode); await FailedEngineAsync(now); + connectionRefreshPending = false; + if (refreshTask != null || subscriptionRefresh.ShouldRefreshOnConnect(settings.UpdatedAt, now, explicitConnectionRefreshPending)) + BeginBackgroundRefresh(true, token); + explicitConnectionRefreshPending = false; + } + if (latencies.Count == 0 && settings.Nodes.Count > 0 && measuring == 0) BeginMeasurements(); + if (engine is { HasExited: true } || xray is { HasExited: true }) + { + Event("VPN-процесс завершился. Восстанавливаем Xray и системный туннель."); await FailedEngineAsync(false); } if (engine == null) { + if (refreshBeforeStart && refreshTask != null) + { + State("connecting", "Обновляем параметры подключения…"); + return; + } if (!recovery.CanAttempt(now)) return; active = PickNode(); + if (active == null && settings.Nodes.Count > 0) { failed.Clear(); active = PickNode(); } if (active == null) { State("error", "Нет доступных локаций. Обновите ключ."); return; } State("connecting", "Подключаемся: " + active.Name); Publish(); try { await StartEngineAsync(active, token); } - catch (Exception e) { await FailedEngineAsync(now); Event(SafeError.Describe(e)); return; } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception e) { await FailedEngineAsync(e is EndpointUnavailableException); Event(e is EndpointUnavailableException ? e.Message : SafeError.Describe(e)); return; } } - if (engine != null && now >= healthDue) + if (engine != null && !socksOnly) { - healthDue = now.AddSeconds(15); - if (await HealthyAsync(token)) + systemRoutesVerified = SystemTunnelProbe.Check().Healthy; + if (systemRoutesVerified == false && state == "connected") + { + State("connecting", "Системные маршруты обходят VPN. Проверяем и восстанавливаем туннель…"); + healthDue = now; + } + } + if (engine != null && healthTask is { IsCompleted: true }) + { + var completed = healthTask; healthTask = null; healthStop?.Dispose(); healthStop = null; + now = DateTimeOffset.UtcNow; + var result = await completed; + // A no-proxy HTTPS request could otherwise succeed over the direct + // route after a TUN failure and falsely report a working VPN. + if (systemRoutesVerified == false) result = result with { SystemAvailable = false }; + if (result.Healthy) { recovery.Healthy(); connectedSince ??= now; - if (now - startedAt >= TimeSpan.FromMinutes(2)) { recovery.Stable(); failed.Clear(); } + healthySince ??= now; + if (now - healthySince >= TimeSpan.FromMinutes(2)) { recovery.Stable(); failed.Clear(); } + if (active != null && settings.LastWorkingId != active.Id) + { + try { Persist(s => s.LastWorkingId = active.Id); } + catch { Event("Не удалось сохранить последнюю рабочую локацию. VPN продолжает работать."); } + } + healthDue = now.AddSeconds(15); State("connected", socksOnly ? "Диагностика: VPN-прокси работает; системный туннель выключен." : "VPN подключён. Соединение проверено."); } - else if (recovery.HealthFailed()) + else { - Event("Три проверки соединения не прошли. Восстанавливаем VPN."); await FailedEngineAsync(now); + healthySince = null; + if (recovery.HealthFailed(connectedSince.HasValue)) + { + bool endpointFailure = systemRoutesVerified != false && !result.SystemPathFailed; + bool refreshNeeded = endpointFailure && (refreshTask != null || subscriptionRefresh.ShouldRefreshAfterEndpointFailure(now)); + Event(!endpointFailure + ? "Системная проверка маршрутов/DNS/HTTPS не прошла. Восстанавливаем туннель на той же локации." + : "Повторные проверки через VPN не прошли. Пробуем доступную локацию."); + await FailedEngineAsync(endpointFailure); + // Fetch through the physical route after removing the failed TUN. + if (refreshNeeded) BeginBackgroundRefresh(true, token); + } + else + { + healthDue = now.AddSeconds(2); + State("connecting", result.SystemPathFailed || systemRoutesVerified == false ? "Проверяем системные маршруты, туннель и DNS…" : "Повторяем проверку связи…"); + } } - else State("recovering", "Проверяем соединение… Автовосстановление включено."); } + if (engine != null && healthTask == null && DateTimeOffset.UtcNow >= healthDue) + { + healthStop = CancellationTokenSource.CreateLinkedTokenSource(token); + healthTask = healthProbe.CheckPathsAsync(!socksOnly, healthStop.Token); + } + // Routine refresh never delays the engine/health loop. + if (state == "connected" && DateTimeOffset.UtcNow >= refreshDue && subscriptionRefresh.CanAttempt(DateTimeOffset.UtcNow)) + BeginBackgroundRefresh(false, token); + } + private void BeginBackgroundRefresh(bool forRecovery, CancellationToken token) + { + if (refreshTask != null) + { + refreshForRecovery |= forRecovery; + if (forRecovery) { refreshBeforeStart = true; refreshStop?.CancelAfter(SubscriptionRefreshPolicy.StartupRefreshBudget); } + return; + } + if (settings.SubscriptionUrl.Length == 0) return; + refreshUrl = settings.SubscriptionUrl; refreshGeneration = subscriptionGeneration; + refreshManualSequence = Volatile.Read(ref manualRefreshSequence); refreshForRecovery = forRecovery; refreshBeforeStart = forRecovery; + subscriptionRefresh.Attempted(DateTimeOffset.UtcNow); + refreshStop = CancellationTokenSource.CreateLinkedTokenSource(token); + if (forRecovery) refreshStop.CancelAfter(SubscriptionRefreshPolicy.StartupRefreshBudget); + var downloadToken = refreshStop.Token; var url = refreshUrl; + // Do not execute synchronous HTTP setup or any network wait while holding gate. + refreshTask = Task.Run(() => Subscription.DownloadAsync(url, downloadToken), CancellationToken.None); + _ = refreshTask.ContinueWith(_ => Wake(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + Event(forRecovery ? "Обновляем параметры подключения в фоне…" : "Обновляем подписку в фоне…"); + } + private async Task CompleteBackgroundRefreshAsync(CancellationToken token) + { + if (refreshTask is not { IsCompleted: true }) return; + var completed = refreshTask; var stop = refreshStop; + bool restore = refreshForRecovery, beforeStart = refreshBeforeStart; + refreshTask = null; refreshStop = null; refreshForRecovery = false; refreshBeforeStart = false; + try + { + var result = await completed; + if (refreshGeneration == subscriptionGeneration && refreshManualSequence == Volatile.Read(ref manualRefreshSequence) + && refreshUrl == settings.SubscriptionUrl) + { + await ApplySubscriptionAsync(refreshUrl!, result); + if (restore) recovery.Reset(); + } + } + catch (Exception e) when (e is not OperationCanceledException || !token.IsCancellationRequested) + { + Event(beforeStart && e is OperationCanceledException ? "Обновление заняло больше 5 секунд. Пробуем сохранённые локации." : SafeError.Describe(e)); + refreshDue = DateTimeOffset.UtcNow.AddMinutes(15); + } + finally { stop?.Dispose(); } } private VpnNode? PickNode() { - if (settings.Nodes.Count == 0) return null; - if (failed.Count >= settings.Nodes.Count) failed.Clear(); - var selected = settings.Nodes.FirstOrDefault(n => n.Id == settings.SelectedId); - if (selected != null && (!settings.AutoFailover || !failed.Contains(selected.Id))) return selected; - return settings.Nodes.Where(n => !failed.Contains(n.Id)).OrderBy(n => latencies.TryGetValue(n.Id, out var latency) ? latency ?? int.MaxValue : 1000) - .ThenBy(n => n.Name.Contains("Цюрих", StringComparison.OrdinalIgnoreCase) ? 0 : 1).FirstOrDefault() ?? settings.Nodes[0]; + return NodeSelection.Choose(settings.Nodes, settings.SelectedId, settings.AutoFailover, settings.LastWorkingId, failed, latencies); } - private async Task FailedEngineAsync(DateTimeOffset now) + private async Task FailedEngineAsync(bool endpointFailure) { - if (active != null && settings.AutoFailover) failed.Add(active.Id); - await StopEngineAsync(); restarts++; recovery.Failed(now); State("recovering", "Восстанавливаем соединение автоматически…"); + if (endpointFailure && active != null && settings.AutoFailover) failed.Add(active.Id); + bool alternative = endpointFailure && settings.AutoFailover && settings.Nodes.Any(n => !failed.Contains(n.Id)); + await StopEngineAsync(); restarts++; recovery.Failed(DateTimeOffset.UtcNow, alternative); State("recovering", "Восстанавливаем соединение автоматически…"); } private async Task StartEngineAsync(VpnNode node, CancellationToken token) { - if (!File.Exists(corePath)) throw new InvalidDataException("Не найден VPN-модуль. Переустановите QVPN."); - await File.WriteAllTextAsync(RuntimePath, ConfigBuilder.Build(node, !socksOnly, settings.RussianDirect, rulesPath).ToJsonString(), token); - var checkInfo = new ProcessStartInfo(corePath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(corePath)! }; - checkInfo.ArgumentList.Add("check"); checkInfo.ArgumentList.Add("-c"); checkInfo.ArgumentList.Add(RuntimePath); - using (var check = Process.Start(checkInfo) ?? throw new InvalidOperationException()) + if (!File.Exists(corePath) || !File.Exists(xrayPath)) throw new InvalidDataException("Не найден VPN-модуль. Переустановите QVPN."); + string? physicalInterface = await DirectLatencyProbe.SelectInterfaceAsync(node, token); + if (physicalInterface == null) throw new EndpointUnavailableException(); + await File.WriteAllTextAsync(XrayRuntimePath, XrayConfigBuilder.Build(node, Constants.XrayPort, physicalInterface).ToJsonString(), token); + await File.WriteAllTextAsync(RuntimePath, ConfigBuilder.BuildXrayBridge(!socksOnly, settings.RussianDirect, rulesPath).ToJsonString(), token); + await CheckConfigAsync(xrayPath, ["run", "-test", "-c", XrayRuntimePath], token); + await CheckConfigAsync(corePath, ["check", "-c", RuntimePath], token); + job = new ProcessJob(); + xray = LaunchOwned(xrayPath, ["run", "-c", XrayRuntimePath]); + // The transport must accept local traffic before Windows routes enter the TUN. + using (var ready = CancellationTokenSource.CreateLinkedTokenSource(token)) { - var stderr = check.StandardError.ReadToEndAsync(token); var stdout = check.StandardOutput.ReadToEndAsync(token); - using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(15000); - try { await check.WaitForExitAsync(limit.Token); await Task.WhenAll(stderr, stdout); } - catch { try { check.Kill(true); } catch { } throw; } - if (check.ExitCode != 0) throw new InvalidDataException("VPN-модуль отклонил конфигурацию. Переустановите QVPN."); + ready.CancelAfter(TimeSpan.FromSeconds(3)); + while (true) + { + ready.Token.ThrowIfCancellationRequested(); + if (xray.HasExited) throw new InvalidDataException("Xray не запустился. Проверьте установку VPN."); + using var tcp = new TcpClient(AddressFamily.InterNetwork); + try { await tcp.ConnectAsync(IPAddress.Loopback, Constants.XrayPort, ready.Token); break; } + catch (SocketException) { await Task.Delay(50, ready.Token); } + } } - var start = new ProcessStartInfo(corePath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(corePath)! }; - start.ArgumentList.Add("run"); start.ArgumentList.Add("-c"); start.ArgumentList.Add(RuntimePath); start.ArgumentList.Add("--disable-color"); - job = new ProcessJob(); engine = Process.Start(start) ?? throw new InvalidOperationException(); - try { job.Add(engine); } catch { try { engine.Kill(true); } catch { } engine.Dispose(); engine = null; job.Dispose(); job = null; throw; } + engine = LaunchOwned(corePath, ["run", "-c", RuntimePath, "--disable-color"]); + connectedSince = null; healthySince = null; healthDue = DateTimeOffset.UtcNow; + Event("Xray и системный туннель запущены: " + node.Name); + } + private sealed class EndpointUnavailableException() : IOException("Не удалось найти рабочий сетевой путь к выбранной локации."); + private static async Task CheckConfigAsync(string executable, string[] arguments, CancellationToken token) + { + using var check = Process.Start(ProcessInfo(executable, arguments)) ?? throw new InvalidOperationException(); + var stderr = check.StandardError.ReadToEndAsync(token); var stdout = check.StandardOutput.ReadToEndAsync(token); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(TimeSpan.FromSeconds(10)); + try { await check.WaitForExitAsync(limit.Token); await Task.WhenAll(stderr, stdout); } + catch { try { check.Kill(true); } catch { } throw; } + if (check.ExitCode != 0) throw new InvalidDataException("VPN-модуль отклонил конфигурацию. Проверьте установку QVPN."); + } + private static ProcessStartInfo ProcessInfo(string executable, string[] arguments) + { + var info = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(executable)! }; + foreach (var argument in arguments) info.ArgumentList.Add(argument); + return info; + } + private Process LaunchOwned(string executable, string[] arguments) + { + var process = Process.Start(ProcessInfo(executable, arguments)) ?? throw new InvalidOperationException(); + try { job!.Add(process); } + catch { try { process.Kill(true); } catch { } process.Dispose(); throw; } // Never persist raw engine output: it can contain destinations and credentials. - _ = DrainAsync(engine.StandardError); _ = DrainAsync(engine.StandardOutput); - startedAt = DateTimeOffset.UtcNow; connectedSince = null; healthDue = startedAt.Value.AddSeconds(3); - Event("VPN-процесс запущен: " + node.Name); + _ = DrainAsync(process.StandardError); _ = DrainAsync(process.StandardOutput); + process.EnableRaisingEvents = true; process.Exited += (_, _) => Wake(); + return process; } private static async Task DrainAsync(StreamReader reader) { try { char[] buffer = new char[2048]; while (await reader.ReadAsync(buffer) > 0) { } } catch (IOException) { } catch (ObjectDisposedException) { } } private async Task StopEngineAsync() { - var process = engine; engine = null; + healthStop?.Cancel(); healthStop?.Dispose(); healthStop = null; + if (healthTask != null) _ = healthTask.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); + healthTask = null; + var processes = new[] { engine, xray }; engine = null; xray = null; job?.Dispose(); job = null; - if (process != null) + foreach (var process in processes.OfType()) { try { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } catch { } finally { process.Dispose(); } } - connectedSince = null; startedAt = null; active = null; + connectedSince = null; healthySince = null; active = null; systemRoutesVerified = null; try { if (File.Exists(RuntimePath)) File.Delete(RuntimePath); } catch { } - } - private async Task HealthyAsync(CancellationToken token) - { - using var handler = new HttpClientHandler { Proxy = new WebProxy("http://127.0.0.1:" + Constants.ProxyPort), UseProxy = true, AllowAutoRedirect = false }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(8) }; - foreach (var url in new[] { "https://cp.cloudflare.com/generate_204", "https://www.gstatic.com/generate_204" }) - { - try { using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token); if (response.StatusCode == HttpStatusCode.NoContent) return true; } - catch (Exception e) when (e is HttpRequestException or OperationCanceledException) { if (token.IsCancellationRequested) throw; } - } - return false; + try { if (File.Exists(XrayRuntimePath)) File.Delete(XrayRuntimePath); } catch { } } private static bool HasOtherVpn() => NetworkInterface.GetAllNetworkInterfaces().Any(n => n.OperationalStatus == OperationalStatus.Up && (n.Name.Contains("happ", StringComparison.OrdinalIgnoreCase) || n.Description.Contains("happ", StringComparison.OrdinalIgnoreCase))); - private static bool PhysicalNetworkAvailable() => NetworkInterface.GetAllNetworkInterfaces().Any(n => n.OperationalStatus == OperationalStatus.Up && n.NetworkInterfaceType != NetworkInterfaceType.Loopback && !n.Name.Equals("QVPN", StringComparison.OrdinalIgnoreCase) - && !n.Name.Contains("happ", StringComparison.OrdinalIgnoreCase) && n.GetIPProperties().GatewayAddresses.Any(g => !g.Address.Equals(IPAddress.Any) && !g.Address.Equals(IPAddress.IPv6Any))); - private async Task MeasureAsync(VpnNode[] nodes) + private void BeginMeasurements() + { + if (settings.Nodes.Count == 0 || measurementStop.IsCancellationRequested) return; + if (Interlocked.CompareExchange(ref measuring, 1, 0) != 0) { Interlocked.Exchange(ref measurementRequested, 1); return; } + Interlocked.Exchange(ref measurementRequested, 0); + var nodes = settings.Nodes.ToArray(); var path = DirectLatencyProbe.GetNetworkState().PathKey; + measureTask = Task.Run(() => MeasureAsync(nodes, path)); Event("Измеряем прямую задержку локаций…"); + } + private async Task MeasureAsync(VpnNode[] nodes, string? path) { try { - using var concurrency = new SemaphoreSlim(6); + using var concurrency = new SemaphoreSlim(12); var results = await Task.WhenAll(nodes.Select(async node => { await concurrency.WaitAsync(measurementStop.Token); try { - using var tcp = new TcpClient(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(measurementStop.Token); timeout.CancelAfter(TimeSpan.FromSeconds(3)); - var watch = Stopwatch.StartNew(); await tcp.ConnectAsync(node.Host, node.Port, timeout.Token); - return (node.Id, Latency: (int?)Math.Max(1, watch.ElapsedMilliseconds)); + return (node.Id, Latency: await DirectLatencyProbe.MeasureAsync(node, measurementStop.Token)); } catch { return (node.Id, Latency: (int?)null); } finally { concurrency.Release(); } })); await gate.WaitAsync(measurementStop.Token); - try { foreach (var result in results) latencies[result.Id] = result.Latency; Event("Задержка локаций измерена (TCP)."); Publish(); } + try + { + if (path == DirectLatencyProbe.GetNetworkState().PathKey) + { + foreach (var result in results) if (settings.Nodes.Any(n => n.Id == result.Id)) latencies[result.Id] = result.Latency; + Event("Прямая задержка локаций измерена (TCP)."); + } + Publish(); + } finally { gate.Release(); } } - finally { Interlocked.Exchange(ref measuring, 0); } + catch (OperationCanceledException) when (measurementStop.IsCancellationRequested) { } + finally + { + Interlocked.Exchange(ref measuring, 0); + if (Interlocked.Exchange(ref measurementRequested, 0) != 0) BeginMeasurements(); + } } public async ValueTask DisposeAsync() { if (disposed) return; disposed = true; measurementStop.Cancel(); - await gate.WaitAsync(); try { await StopEngineAsync(); } finally { gate.Release(); } + await gate.WaitAsync(); + try + { + refreshStop?.Cancel(); refreshStop?.Dispose(); refreshStop = null; + if (refreshTask != null) _ = refreshTask.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); + refreshTask = null; + await StopEngineAsync(); + } + finally { gate.Release(); } if (measureTask != null) try { await measureTask.WaitAsync(TimeSpan.FromSeconds(5)); } catch { } + healthProbe.Dispose(); } } diff --git a/src/QVPN.Setup/Installer.cs b/src/QVPN.Setup/Installer.cs index c3ffdd2..ae909d6 100644 --- a/src/QVPN.Setup/Installer.cs +++ b/src/QVPN.Setup/Installer.cs @@ -60,7 +60,7 @@ internal sealed class Installer(Action progress) key.SetValue("OwnerSid", owner.Value); key.SetValue("InstallPath", InstallRoot); key.SetValue("DataPath", DataRoot); - key.SetValue("Version", "1.0.1"); + key.SetValue("Version", typeof(Installer).Assembly.GetName().Version!.ToString(3)); } progress("Настройка автоматического запуска и восстановления службы…"); ConfigureService(previousService); @@ -155,7 +155,7 @@ internal sealed class Installer(Action progress) private static void VerifyBundle(string root) { - string[] required = [@"app\QVPN.exe", @"service\QVPN.Service.exe", @"core\sing-box.exe", @"core\libcronet.dll", @"core\LICENSE"]; + string[] required = [@"app\QVPN.exe", @"service\QVPN.Service.exe", @"core\sing-box.exe", @"core\libcronet.dll", @"core\LICENSE", @"core\xray.exe", @"core\LICENSE-Xray", @"core\wintun.dll", @"core\LICENSE-Wintun"]; foreach (string relative in required) if (!File.Exists(Path.Combine(root, relative))) throw new InvalidDataException("В установщике отсутствует " + relative); if (!Directory.Exists(Path.Combine(root, "rules")) || Directory.GetFiles(Path.Combine(root, "rules"), "*.srs").Length == 0) @@ -247,7 +247,7 @@ internal sealed class Installer(Action progress) { using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true); string command = "\"" + Path.Combine(InstallRoot, "QVPN-Setup.exe") + "\" --uninstall"; - key.SetValue("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", "1.0.1"); + key.SetValue("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", typeof(Installer).Assembly.GetName().Version!.ToString(3)); key.SetValue("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot); key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe")); key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent"); diff --git a/tests/QVPN.Tests/HealthProbeTests.cs b/tests/QVPN.Tests/HealthProbeTests.cs new file mode 100644 index 0000000..8f764e1 --- /dev/null +++ b/tests/QVPN.Tests/HealthProbeTests.cs @@ -0,0 +1,349 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using QVPN.Core; + +public static class HealthProbeTests +{ + private static readonly Uri[] Endpoints = + [ + new("https://first.example/generate_204"), + new("https://second.example/generate_204"), + new("https://third.example/generate_204") + ]; + + public static async Task RunAsync() + { + await SuccessCancelsPendingAsync(); + await SharedBudgetBoundsLatencyAsync(); + await CallerCancellationPropagatesAsync(); + await FailuresDoNotProduceSuccessAsync(); + await ThirdEndpointRecoversThrottledPeersAsync(); + await FreshConnectionsDoNotReuseSuccessfulSessionAsync(); + await TunRequiresBothPathsAsync(); + await TunPathsShareBudgetAsync(); + await ProxySuccessDoesNotMaskSystemTimeoutAsync(); + await TunCallerCancellationPropagatesAsync(); + await SocksModeSkipsSystemPathAsync(); + } + + public static async Task SuccessCancelsPendingAsync() + { + var peersStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var peersCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int started = 0, cancelled = 0; + using var handler = new StubHandler(async (request, token) => + { + if (request.RequestUri!.Host == Endpoints[0].Host) + { + // Success requires both peers to be in flight: a sequential probe cannot pass. + await peersStarted.Task.WaitAsync(token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + if (Interlocked.Increment(ref started) == 2) peersStarted.TrySetResult(); + try { await Task.Delay(Timeout.InfiniteTimeSpan, token); } + catch (OperationCanceledException) + { + if (Interlocked.Increment(ref cancelled) == 2) peersCancelled.TrySetResult(); + throw; + } + throw new InvalidOperationException("A pending transport unexpectedly completed."); + }); + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, Endpoints, TimeSpan.FromSeconds(1)); + Check(await probe.CheckAsync(), "Parallel successful endpoint was not accepted."); + await peersCancelled.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Check(cancelled == 2, "Successful probe did not cancel both losing requests."); + } + + public static async Task SharedBudgetBoundsLatencyAsync() + { + using var release = new CancellationTokenSource(); + var allCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int started = 0, cancelled = 0; + using var handler = new StubHandler(async (_, token) => + { + Interlocked.Increment(ref started); + using var registration = token.Register(() => + { + if (Interlocked.Increment(ref cancelled) == Endpoints.Length) allCancelled.TrySetResult(); + }); + // Model a transport that acknowledges cancellation but does not finish promptly. + await Task.Delay(Timeout.InfiniteTimeSpan, release.Token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, Endpoints, TimeSpan.FromMilliseconds(100)); + var watch = Stopwatch.StartNew(); + try + { + Check(!await probe.CheckAsync(), "An expired budget reported success."); + watch.Stop(); + Check(watch.Elapsed < TimeSpan.FromSeconds(1), "Probe waited beyond its shared budget for losing transports."); + await allCancelled.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Check(started == 3 && cancelled == 3, "Budget did not start and cancel all parallel probes."); + } + finally { release.Cancel(); } + } + + public static async Task CallerCancellationPropagatesAsync() + { + var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int started = 0; + using var handler = new StubHandler(async (_, token) => + { + if (Interlocked.Increment(ref started) == Endpoints.Length) allStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + throw new InvalidOperationException("A cancelled transport unexpectedly completed."); + }); + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, Endpoints); + using var cancellation = new CancellationTokenSource(); + var running = probe.CheckAsync(cancellation.Token); + await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + try + { + await running.WaitAsync(TimeSpan.FromSeconds(1)); + throw new InvalidOperationException("Caller cancellation was converted into a health failure."); + } + catch (OperationCanceledException error) + { + Check(error.CancellationToken == cancellation.Token, "Cancellation did not preserve the caller's token."); + } + } + + public static async Task FailuresDoNotProduceSuccessAsync() + { + int requests = 0; + using var handler = new StubHandler((request, _) => + { + Interlocked.Increment(ref requests); + if (request.RequestUri!.Host == Endpoints[0].Host) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("204") }); + if (request.RequestUri.Host == Endpoints[1].Host) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Redirect)); + return Task.FromException(new HttpRequestException("Synthetic transport failure.")); + }); + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, Endpoints); + Check(!await probe.CheckAsync(), "A redirect, HTTP 200, or transport error was accepted as healthy."); + Check(requests == Endpoints.Length, "Not all independent failure endpoints were checked."); + } + + public static async Task ThirdEndpointRecoversThrottledPeersAsync() + { + using var handler = new StubHandler((request, _) => Task.FromResult(new HttpResponseMessage( + request.RequestUri!.Host == Endpoints[2].Host ? HttpStatusCode.NoContent : HttpStatusCode.TooManyRequests))); + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, Endpoints); + Check(await probe.CheckAsync(), "Throttled peers masked the third successful endpoint."); + } + + public static async Task FreshConnectionsDoNotReuseSuccessfulSessionAsync() + { + using var key = RSA.Create(2048); + var certificateRequest = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var generated = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(5)); + // Schannel cannot serve TLS with the ephemeral key returned above. + // Import a temporary user key that is removed when the certificate is disposed. + using var certificate = X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.UserKeySet); + using var serverStop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int connections = 0; + var server = Task.Run(async () => + { + var workers = new List(); + try + { + while (true) + { + var tcp = await listener.AcceptTcpClientAsync(serverStop.Token); + Interlocked.Increment(ref connections); + workers.Add(ServeKeepAliveAsync(tcp, certificate, serverStop.Token)); + } + } + catch (OperationCanceledException) when (serverStop.IsCancellationRequested) { } + finally { await Task.WhenAll(workers); } + }); + using var handler = new SocketsHttpHandler + { + UseProxy = false, + SslOptions = new SslClientAuthenticationOptions + { + // Trust only this synthetic loopback server's ephemeral certificate. + RemoteCertificateValidationCallback = (_, remote, _, _) => remote?.GetCertHashString() == certificate.GetCertHashString() + } + }; + using var http = new HttpClient(handler); + using var probe = new HealthProbe(http, + [new Uri("https://127.0.0.1:" + ((IPEndPoint)listener.LocalEndpoint).Port + "/generate_204")]); + try + { + Check(await probe.CheckAsync(), "First synthetic HTTPS check failed."); + Check(await probe.CheckAsync(), "Second synthetic HTTPS check failed."); + Check(Volatile.Read(ref connections) == 2, "Health reused an old successful HTTPS session instead of testing a new connection."); + } + finally + { + serverStop.Cancel(); + await server.WaitAsync(TimeSpan.FromSeconds(2)); + listener.Stop(); + } + } + + private static async Task ServeKeepAliveAsync(TcpClient tcp, X509Certificate2 certificate, CancellationToken token) + { + using (tcp) + using (var tls = new SslStream(tcp.GetStream())) + { + try + { + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate }, token); + var buffer = new byte[4096]; + var headers = new StringBuilder(); + while (true) + { + int count = await tls.ReadAsync(buffer, token); + if (count == 0) return; + headers.Append(Encoding.ASCII.GetString(buffer, 0, count)); + if (!headers.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) continue; + bool close = headers.ToString().Contains("\r\nConnection: close\r\n", StringComparison.OrdinalIgnoreCase); + headers.Clear(); + // Permit persistent connections unless the health request + // explicitly asks to close this session. + await tls.WriteAsync("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"u8.ToArray(), token); + if (close) return; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (System.Security.Authentication.AuthenticationException) when (token.IsCancellationRequested) { } + catch (IOException) { } + } + } + + public static async Task TunRequiresBothPathsAsync() + { + foreach (var (proxyHealthy, systemHealthy) in new[] { (true, false), (false, true), (true, true) }) + { + using var proxyHandler = new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(proxyHealthy ? HttpStatusCode.NoContent : HttpStatusCode.BadGateway))); + using var systemHandler = new StubHandler(async (_, token) => + { + // Proxy success must not cancel the still-running system check. + await Task.Delay(25, token); + return new HttpResponseMessage(systemHealthy ? HttpStatusCode.NoContent : HttpStatusCode.BadGateway); + }); + using var proxy = new HttpClient(proxyHandler); + using var system = new HttpClient(systemHandler); + using var probe = new HealthProbe(proxy, Endpoints, TimeSpan.FromSeconds(1), system); + var result = await probe.CheckPathsAsync(true); + Check(result.ProxyAvailable == proxyHealthy && result.SystemAvailable == systemHealthy, "TUN check confused proxy and system reachability."); + Check(result.Healthy == (proxyHealthy && systemHealthy), "TUN health accepted only one working path."); + Check(result.SystemPathFailed == (proxyHealthy && !systemHealthy), "Local tunnel failure was attributed to the VPN endpoint."); + } + } + + public static async Task TunPathsShareBudgetAsync() + { + int started = 0, cancelled = 0; + var allCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new CancellationTokenSource(); + using var handler = new StubHandler(async (_, token) => + { + Interlocked.Increment(ref started); + using var registration = token.Register(() => { if (Interlocked.Increment(ref cancelled) == Endpoints.Length * 2) allCancelled.TrySetResult(); }); + await Task.Delay(Timeout.InfiniteTimeSpan, release.Token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var proxy = new HttpClient(handler, false); + using var system = new HttpClient(handler, false); + using var probe = new HealthProbe(proxy, Endpoints, TimeSpan.FromMilliseconds(100), system); + var watch = Stopwatch.StartNew(); + try + { + var result = await probe.CheckPathsAsync(true); + Check(!result.Healthy && !result.ProxyAvailable && result.SystemAvailable == false, "Timed-out TUN paths reported health."); + Check(watch.Elapsed < TimeSpan.FromSeconds(1), "TUN paths exceeded the shared deadline."); + await allCancelled.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Check(started == Endpoints.Length * 2 && cancelled == Endpoints.Length * 2, "Both TUN paths were not checked and cancelled in parallel."); + } + finally { release.Cancel(); } + } + + public static async Task TunCallerCancellationPropagatesAsync() + { + int started = 0; + var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var handler = new StubHandler(async (_, token) => + { + if (Interlocked.Increment(ref started) == Endpoints.Length * 2) allStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var proxy = new HttpClient(handler, false); + using var system = new HttpClient(handler, false); + using var probe = new HealthProbe(proxy, Endpoints, systemHttp: system); + using var cancellation = new CancellationTokenSource(); + var running = probe.CheckPathsAsync(true, cancellation.Token); + await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + try + { + await running.WaitAsync(TimeSpan.FromSeconds(1)); + throw new InvalidOperationException("TUN cancellation was reported as endpoint failure."); + } + catch (OperationCanceledException error) { Check(error.CancellationToken == cancellation.Token, "TUN cancellation lost the caller's token."); } + } + + public static async Task ProxySuccessDoesNotMaskSystemTimeoutAsync() + { + using var release = new CancellationTokenSource(); + using var proxyHandler = new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent))); + using var systemHandler = new StubHandler(async (_, _) => + { + // A Windows route/DNS transport need not honor cancellation promptly. + await Task.Delay(Timeout.InfiniteTimeSpan, release.Token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var proxy = new HttpClient(proxyHandler); + using var system = new HttpClient(systemHandler); + using var probe = new HealthProbe(proxy, Endpoints, TimeSpan.FromMilliseconds(100), system); + try + { + var result = await probe.CheckPathsAsync(true).WaitAsync(TimeSpan.FromSeconds(1)); + Check(result.SystemPathFailed && !result.Healthy, "Proxy success hid a timed-out Windows path or penalized the endpoint."); + } + finally { release.Cancel(); } + } + + public static async Task SocksModeSkipsSystemPathAsync() + { + int systemRequests = 0; + using var proxyHandler = new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent))); + using var systemHandler = new StubHandler((_, _) => + { + Interlocked.Increment(ref systemRequests); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)); + }); + using var proxy = new HttpClient(proxyHandler); + using var system = new HttpClient(systemHandler); + using var probe = new HealthProbe(proxy, Endpoints, systemHttp: system); + var result = await probe.CheckPathsAsync(false); + Check(result.Healthy && result.SystemAvailable is null && systemRequests == 0, "SOCKS diagnostics unexpectedly required the system tunnel."); + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private sealed class StubHandler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token); + } +} diff --git a/tests/QVPN.Tests/NodeSelectionTests.cs b/tests/QVPN.Tests/NodeSelectionTests.cs new file mode 100644 index 0000000..a45ba4e --- /dev/null +++ b/tests/QVPN.Tests/NodeSelectionTests.cs @@ -0,0 +1,172 @@ +using QVPN.Core; + +internal static class NodeSelectionTests +{ + private static readonly VpnNode First = Node("first"); + private static readonly VpnNode Second = Node("second"); + private static readonly VpnNode Third = Node("third"); + private static readonly VpnNode[] Nodes = [First, Second, Third]; + + public static async Task RunAsync(Func test) + { + await test("Selected Zurich survives server and credential rotation in a reordered subscription", () => + { + var zurich = First with { Id = "zurich-old", Name = "Цюрих", Host = "203.0.113.10" }; + var replacement = zurich with + { + Id = "zurich-new", Name = " ЦЮРИХ\t", Host = "203.0.113.20", Port = 8443, + Uuid = "00000000-0000-4000-8000-000000000003", PublicKey = "rotated-fixture-key", ShortId = "1234" + }; + var amsterdam = Second with { Name = "Амстердам", Host = "203.0.113.30" }; + VpnNode[] refreshed = [amsterdam, replacement]; + var selected = NodeSelection.FindReplacement(zurich, refreshed); + Check(selected == replacement); + Check(NodeSelection.Choose(refreshed, selected!.Id, true, selected.Id, new HashSet(), + new Dictionary { [amsterdam.Id] = 1 }) == replacement); + // Preserving the location must still restart the engine when its transport changed. + Check(!NodeSelection.ConnectionEquals(zurich, replacement)); + }); + await test("Replacement prefers exact ID before matching a location name", () => + { + var exact = First with { Name = "Updated presentation" }; + var sameName = Second with { Name = First.Name }; + Check(NodeSelection.FindReplacement(First, [sameName, exact]) == exact); + }); + await test("Saved transport survives ID migration among identical location names and endpoints", () => + { + var unchanged = First with { Id = "new-id" }; + var alternateFlow = unchanged with { Id = "other-flow", Flow = "xtls-rprx-vision-udp443" }; + var alternateFingerprint = unchanged with { Id = "other-fingerprint", Fingerprint = "firefox" }; + Check(NodeSelection.FindReplacement(First, [alternateFlow, alternateFingerprint, unchanged]) == unchanged); + }); + await test("Unchanged subscription refresh retains failures and tries a fresh server", () => + { + var rejected = First with { Id = "new-id", Name = "Renamed" }; + var fresh = Second with { Host = "203.0.113.2" }; + var failed = NodeSelection.RemapFailedConnections([First, fresh], [rejected, fresh], new HashSet { First.Id }); + Check(failed.SetEquals([rejected.Id])); + Check(NodeSelection.Choose([rejected, fresh], rejected.Id, true, rejected.Id, failed, new Dictionary()) == fresh); + }); + await test("Changed credentials or transport allow retry after subscription refresh", () => + { + var rotated = First with { Id = "rotated", ShortId = "1234" }; + var flowChanged = First with { Id = "flow", Flow = "xtls-rprx-vision-udp443" }; + var fingerprintChanged = First with { Id = "fingerprint", Fingerprint = "firefox" }; + var failed = NodeSelection.RemapFailedConnections([First], [rotated, flowChanged, fingerprintChanged], new HashSet { First.Id }); + Check(failed.Count == 0); + }); + await test("Replacement normalizes Unicode and whitespace in a unique location name", () => + { + var old = First with { Name = " Zurich\u00a0\u00a0Cafe\u0301 " }; + var replacement = Second with { Name = "zurich CAFÉ", Host = "203.0.113.20" }; + Check(NodeSelection.FindReplacement(old, [Third, replacement]) == replacement); + }); + await test("A unique unchanged endpoint disambiguates duplicated location names", () => + { + var old = First with { Name = "Цюрих", Host = "203.0.113.10" }; + var replacement = Second with { Name = old.Name, Host = old.Host, Port = old.Port }; + var duplicateName = Third with { Name = old.Name, Host = "203.0.113.20" }; + Check(NodeSelection.FindReplacement(old, [duplicateName, replacement]) == replacement); + Check(NodeSelection.FindReplacement(old, [replacement with { Name = "Renamed location" }]) is not null); + }); + await test("Unmatched or ambiguous rotated locations have no guessed replacement", () => + { + var old = First with { Name = "Цюрих", Host = "203.0.113.10" }; + var one = Second with { Name = old.Name, Host = "203.0.113.20" }; + var two = Third with { Name = " ЦЮРИХ ", Host = "203.0.113.30" }; + Check(NodeSelection.FindReplacement(old, [one, two]) is null); + Check(NodeSelection.FindReplacement(old, [Second with { Name = "Амстердам", Host = "203.0.113.40" }]) is null); + Check(NodeSelection.FindReplacement(old, [one with { Host = old.Host }, two with { Host = old.Host }]) is null); + Check(NodeSelection.FindReplacement(old, []) is null); + }); + await test("Manual location remains selected after failure and a negative TCP probe", () => + { + var failed = new HashSet { Second.Id }; + var latencies = new Dictionary { [First.Id] = 10, [Second.Id] = null }; + Check(NodeSelection.Choose(Nodes, Second.Id, false, First.Id, failed, latencies) == Second); + }); + await test("Disabling failover preserves the working server when no location is selected", () => + { + Check(NodeSelection.Choose(Nodes, "missing", false, Second.Id, + new HashSet { First.Id }, new Dictionary { [Third.Id] = 1 }) == Second); + Check(NodeSelection.Choose(Nodes, "missing", false, "removed", new HashSet(), new Dictionary()) == First); + }); + await test("Automatic switching tries every fresh location before any failed location", () => + { + var failed = new HashSet { First.Id, Second.Id }; + var latencies = new Dictionary { [First.Id] = 1, [Second.Id] = 2, [Third.Id] = 300 }; + Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) == Third); + failed.Add(Third.Id); + Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) is null); + failed.Clear(); + Check(NodeSelection.Choose(Nodes, "", true, null, failed, latencies) == First); + }); + await test("Explicit location is attempted despite a negative TCP measurement", () => + { + Check(NodeSelection.Choose(Nodes, Second.Id, true, Third.Id, new HashSet(), + new Dictionary { [First.Id] = 1, [Second.Id] = null, [Third.Id] = 2 }) == Second); + }); + await test("Last working location remains sticky across subscription order and latency changes", () => + { + var latencies = new Dictionary { [First.Id] = 1, [Second.Id] = 80, [Third.Id] = 2 }; + Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet(), latencies) == Second); + Check(NodeSelection.Choose([Third, First, Second], "", true, Second.Id, new HashSet(), latencies) == Second); + Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet { Second.Id }, latencies) == First); + }); + await test("Fresh reachable locations rank by latency ahead of missing and negative measurements", () => + { + var latencies = new Dictionary { [First.Id] = null, [Second.Id] = 3000 }; + Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == Second); + latencies[Third.Id] = 20; + Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == Third); + }); + await test("TCP timeout does not discard a verified server; failed VPN checks do", () => + { + Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet(), + new Dictionary { [First.Id] = null, [Third.Id] = null }) == First); + Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet { First.Id }, + new Dictionary { [First.Id] = null, [Third.Id] = null }) == Second); + }); + await test("All negative TCP measurements still allow an untried VPN attempt", () => + { + var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)null); + Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == First); + Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet { First.Id }, latencies) == Second); + Check(NodeSelection.Choose(Nodes, "", true, Third.Id, new HashSet(), latencies) == Third); + }); + await test("Removed last working location and latency ties use stable fresh candidates", () => + { + var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)25); + Check(NodeSelection.Choose(Nodes, "removed", true, "removed", new HashSet(), latencies) == First); + Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet { First.Id }, latencies) == Second); + Check(NodeSelection.Choose([], "", true, null, new HashSet(), latencies) is null); + }); + await test("Subscription presentation changes do not alter VPN connection identity", () => + { + Check(NodeSelection.ConnectionEquals(First, First with { Name = "Renamed location", Id = "new presentation id" })); + Check(NodeSelection.ConnectionEquals(null, null)); + Check(!NodeSelection.ConnectionEquals(First, null)); + Check(!NodeSelection.ConnectionEquals(null, First)); + }); + await test("Every changed connection field requires a new VPN connection", () => + { + VpnNode[] changed = + [ + First with { Host = "203.0.113.2" }, First with { Port = 8443 }, + First with { Uuid = "00000000-0000-4000-8000-000000000002" }, + First with { ServerName = "other.invalid" }, First with { PublicKey = "other-test-key" }, + First with { ShortId = "abce" }, First with { Fingerprint = "firefox" }, + First with { Flow = "" } + ]; + foreach (var node in changed) Check(!NodeSelection.ConnectionEquals(First, node)); + }); + } + + private static VpnNode Node(string id) => new(id, "Test " + id, "203.0.113.1", 443, + "00000000-0000-4000-8000-000000000001", "test.invalid", "fixture-key", "abcd", "chrome", "xtls-rprx-vision"); + + private static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("Node selection regression."); + } +} diff --git a/tests/QVPN.Tests/Program.cs b/tests/QVPN.Tests/Program.cs index b514185..c225e8e 100644 --- a/tests/QVPN.Tests/Program.cs +++ b/tests/QVPN.Tests/Program.cs @@ -137,6 +137,49 @@ static class Runner Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed()); Check(p.ConsecutiveFailures == 3); p.Healthy(); Check(p.ConsecutiveFailures == 0); }); + await Test("New endpoint fails quickly; established tunnel tolerates a transient miss", () => + { + var p = new RecoveryPolicy(); Check(!p.HealthFailed(false)); Check(p.HealthFailed(false)); + p.Healthy(); Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed()); + }); + await Test("Fresh alternatives do not inherit exponential outage backoff", () => + { + var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow; + for (int i = 0; i < 20; i++) { p.Failed(now, true); Check(p.NextAttempt == now.AddMilliseconds(250)); Check(p.Attempts == 0); } + p.Failed(now); Check(p.NextAttempt == now.AddSeconds(2)); + p.Failed(now); Check(p.NextAttempt == now.AddSeconds(4)); + p.Failed(now, true); Check(p.NextAttempt == now.AddMilliseconds(250)); + p.Reset(); Check(p.CanAttempt(now) && p.Attempts == 0); + }); + await NodeSelectionTests.RunAsync((name, action) => Test(name, action)); + await SubscriptionTransportTests.RunAsync((name, action) => Test(name, action)); + await SystemTunnelProbeTests.RunAsync((name, action) => Test(name, action)); + await Test("Subscription deadline covers headers and a stalled body", SubscriptionDownloadTests.HeadersDoNotEndBodyDeadlineAsync); + await Test("Caller cancellation wins the subscription body deadline", SubscriptionDownloadTests.CallerCancellationWinsBodyDeadlineAsync); + await Test("Bounded subscription download preserves provider and body validation", SubscriptionDownloadTests.CompletedBodyPreservesValidationAsync); + await XrayConfigTests.RunAsync((name, action) => Test(name, action)); + await SubscriptionRefreshPolicyTests.RunAsync((name, action) => Test(name, action)); + await Test("System TUN routes all proxy traffic through local Xray without storing credentials", () => + { + var config = ConfigBuilder.BuildXrayBridge(true, true, Path.GetTempPath()); + var proxy = config["outbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue() == "proxy")!; + Check(proxy["type"]!.GetValue() == "socks" && proxy["server"]!.GetValue() == "127.0.0.1"); + Check(proxy["server_port"]!.GetValue() == Constants.XrayPort && proxy["version"]!.GetValue() == "5"); + Check(!config.ToJsonString().Contains(FakeUuid) && !config.ToJsonString().Contains("public_key")); + Check(config["inbounds"]!.AsArray().Any(n => n?["type"]?.GetValue() == "tun")); + Check(config["dns"]!["servers"]!.AsArray().Single()!["detour"]!.GetValue() == "proxy"); + }); + await Test("Parallel health success cancels pending requests", HealthProbeTests.SuccessCancelsPendingAsync); + await Test("Health requests share a bounded timeout", HealthProbeTests.SharedBudgetBoundsLatencyAsync); + await Test("Health caller cancellation propagates", HealthProbeTests.CallerCancellationPropagatesAsync); + await Test("Health rejects redirects, transport errors and HTTP 200", HealthProbeTests.FailuresDoNotProduceSuccessAsync); + await Test("Third health endpoint tolerates throttled peers", HealthProbeTests.ThirdEndpointRecoversThrottledPeersAsync); + await Test("Health opens fresh HTTPS sessions against a keep-alive server", HealthProbeTests.FreshConnectionsDoNotReuseSuccessfulSessionAsync); + await Test("TUN health requires both proxy and system DNS/HTTPS paths", HealthProbeTests.TunRequiresBothPathsAsync); + await Test("TUN paths share one bounded health deadline", HealthProbeTests.TunPathsShareBudgetAsync); + await Test("Proxy success cannot mask a timed-out system tunnel", HealthProbeTests.ProxySuccessDoesNotMaskSystemTimeoutAsync); + await Test("TUN health propagates caller cancellation", HealthProbeTests.TunCallerCancellationPropagatesAsync); + await Test("SOCKS diagnostics skip the system tunnel check", HealthProbeTests.SocksModeSkipsSystemPathAsync); await Test("Recovery backoff bounded; one healthy probe does not clear attempts", () => { var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow; @@ -156,6 +199,10 @@ static class Runner { try { + if (args.Length == 2 && args[0] == "--xray-check") + { + await XrayConfigTests.CheckEngineAsync(Path.GetFullPath(args[1])); return 0; + } if (args.Length == 3 && args[0] == "--malformed-service" && args[1] == "--evidence-file") return await MalformedServiceAsync(args[2]); if (args.Length is 3 or 4 && args[0] == "--download" && args[1] == "--value-file" && (args.Length == 3 || args[3] == "--diagnose")) diff --git a/tests/QVPN.Tests/SubscriptionDownloadTests.cs b/tests/QVPN.Tests/SubscriptionDownloadTests.cs new file mode 100644 index 0000000..a6a5b41 --- /dev/null +++ b/tests/QVPN.Tests/SubscriptionDownloadTests.cs @@ -0,0 +1,127 @@ +using System.Diagnostics; +using System.Net; +using QVPN.Core; + +public static class SubscriptionDownloadTests +{ + private static readonly string ProviderUrl = "https://test.withblancvpn.online/s/" + new string('a', 32); + + public static async Task HeadersDoNotEndBodyDeadlineAsync() + { + using var caller = new CancellationTokenSource(); + using var body = new StallingStream(); + bool headersReturned = false; + using var handler = new StubHandler(async (_, token) => + { + await Task.Delay(30, token); + headersReturned = true; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) }; + }); + using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + var watch = Stopwatch.StartNew(); + try + { + try + { + await Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromMilliseconds(150), caller.Token) + .WaitAsync(TimeSpan.FromSeconds(1)); + throw new InvalidOperationException("A stalled subscription body unexpectedly completed."); + } + catch (OperationCanceledException) + { + Check(!caller.IsCancellationRequested, "The download required caller cancellation instead of its own body deadline."); + } + Check(headersReturned && body.ReadStarted.Task.IsCompleted, "The regression did not reach the streamed body after receiving headers."); + Check(body.ReadCancelled && body.Disposed, "The timed-out response did not cancel its body read and dispose the transport."); + Check(watch.Elapsed < TimeSpan.FromSeconds(1), "The complete request exceeded its body deadline."); + } + finally { caller.Cancel(); } + } + + public static async Task CallerCancellationWinsBodyDeadlineAsync() + { + using var caller = new CancellationTokenSource(); + using var body = new StallingStream(); + using var handler = new StubHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) })); + using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + var running = Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromSeconds(5), caller.Token); + await body.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + caller.Cancel(); + try + { + await running.WaitAsync(TimeSpan.FromSeconds(1)); + throw new InvalidOperationException("Caller cancellation was converted into a successful subscription."); + } + catch (OperationCanceledException error) + { + Check(error.CancellationToken == caller.Token, "The shorter caller deadline lost its original cancellation token."); + } + Check(body.ReadCancelled && body.Disposed, "Caller cancellation did not stop and dispose the streamed response."); + } + + public static async Task CompletedBodyPreservesValidationAsync() + { + int requests = 0; + using var handler = new StubHandler((request, _) => + { + Interlocked.Increment(ref requests); + Check(request.RequestUri!.Scheme == Uri.UriSchemeHttps && request.RequestUri.Host == "test.withblancvpn.online", + "The bounded download changed the validated HTTPS request."); + string uuid = new Guid(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()).ToString(); + string key = Convert.ToBase64String(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($"vless://{uuid}@8.8.8.8:443?security=reality&type=tcp&pbk={key}&sid=abcd&sni=example.com&fp=chrome&flow=xtls-rprx-vision-udp443#Synthetic") + }; + response.Headers.TryAddWithoutValidation("subscription-userinfo", "upload=0; download=1"); + return Task.FromResult(response); + }); + using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + var result = await Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromSeconds(1), CancellationToken.None); + Check(result.Nodes.Count == 1 && result.Nodes[0].Flow == "xtls-rprx-vision-udp443" && result.Info == "upload=0; download=1", + "A complete bounded response lost nodes, supported transport fields, or subscription metadata."); + try + { + await Subscription.FetchWithClientAsync("https://example.org/s/" + new string('a', 32), http, TimeSpan.FromSeconds(1), CancellationToken.None); + throw new InvalidOperationException("Injected transport bypassed provider validation."); + } + catch (ArgumentException) { } + Check(requests == 1, "An untrusted provider URL reached the injected transport."); + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + private sealed class StubHandler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token); + } + + private sealed class StallingStream : Stream + { + public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool ReadCancelled { get; private set; } + public bool Disposed { get; private set; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken token = default) + { + ReadStarted.TrySetResult(); + try { await Task.Delay(Timeout.InfiniteTimeSpan, token); } + catch (OperationCanceledException) { ReadCancelled = true; throw; } + return 0; + } + protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/tests/QVPN.Tests/SubscriptionRefreshPolicyTests.cs b/tests/QVPN.Tests/SubscriptionRefreshPolicyTests.cs new file mode 100644 index 0000000..6fbffd8 --- /dev/null +++ b/tests/QVPN.Tests/SubscriptionRefreshPolicyTests.cs @@ -0,0 +1,61 @@ +using QVPN.Core; + +internal static class SubscriptionRefreshPolicyTests +{ + public static async Task RunAsync(Func test) + { + var now = DateTimeOffset.Parse("2026-10-06T21:00:00Z"); + await test("Service startup refreshes stale or undated subscription before health succeeds", () => + { + var policy = new SubscriptionRefreshPolicy(); + Check(policy.ShouldRefreshOnConnect(now.AddHours(-2), now)); + Check(policy.ShouldRefreshOnConnect(null, now)); + Check(policy.ShouldRefreshOnConnect(now.AddMinutes(-15), now)); + Check(!policy.ShouldRefreshOnConnect(now.AddMinutes(-14), now)); + }); + await test("A new explicit connect checks current endpoint even with recently cached subscription", () => + { + var policy = new SubscriptionRefreshPolicy(); + Check(policy.ShouldRefreshOnConnect(now, now, explicitConnect: true)); + policy.Attempted(now); + Check(!policy.ShouldRefreshOnConnect(now, now.AddSeconds(59), explicitConnect: true)); + Check(policy.ShouldRefreshOnConnect(now, now.AddSeconds(60), explicitConnect: true)); + }); + await test("Endpoint outage refresh is allowed without a connected engine and limited to once per minute", () => + { + var policy = new SubscriptionRefreshPolicy(); + Check(policy.ShouldRefreshAfterEndpointFailure(now)); + policy.Attempted(now); + for (int seconds = 0; seconds < 60; seconds++) Check(!policy.ShouldRefreshAfterEndpointFailure(now.AddSeconds(seconds))); + Check(policy.ShouldRefreshAfterEndpointFailure(now.AddSeconds(60))); + }); + await test("Manual or routine subscription attempts also suppress immediate outage refresh loops", () => + { + var policy = new SubscriptionRefreshPolicy(); + policy.Attempted(now); + Check(!policy.ShouldRefreshAfterEndpointFailure(now.AddSeconds(1))); + Check(!policy.ShouldRefreshOnConnect(now.AddHours(-2), now.AddSeconds(1))); + policy.Attempted(now.AddSeconds(60)); + Check(!policy.ShouldRefreshAfterEndpointFailure(now.AddSeconds(61))); + }); + await test("A fresh successful subscription prevents automatic startup refresh after the retry cooldown", () => + { + var policy = new SubscriptionRefreshPolicy(); + policy.Attempted(now); + Check(!policy.ShouldRefreshOnConnect(now, now.AddMinutes(2))); + Check(policy.ShouldRefreshOnConnect(now, now.AddMinutes(15))); + }); + await test("Clock rollback does not cause repeated automatic subscription refresh", () => + { + var policy = new SubscriptionRefreshPolicy(); + policy.Attempted(now); + Check(!policy.ShouldRefreshAfterEndpointFailure(now.AddMinutes(-5))); + Check(!policy.ShouldRefreshOnConnect(now.AddHours(-2), now.AddMinutes(-5), explicitConnect: true)); + }); + } + + private static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("Subscription refresh policy regression."); + } +} diff --git a/tests/QVPN.Tests/SubscriptionTransportTests.cs b/tests/QVPN.Tests/SubscriptionTransportTests.cs new file mode 100644 index 0000000..856d27b --- /dev/null +++ b/tests/QVPN.Tests/SubscriptionTransportTests.cs @@ -0,0 +1,63 @@ +using System.Text; +using QVPN.Core; + +public static class SubscriptionTransportTests +{ + public static async Task RunAsync(Func test) + { + await test("Subscription keeps distinct flow and fingerprint variants of one endpoint", () => + { + var nodes = Subscription.Parse(string.Join('\n', + Link("xtls-rprx-vision", "chrome"), Link("", "chrome"), + Link("xtls-rprx-vision", "firefox"), Link("xtls-rprx-vision-udp443", "chrome"))); + Check(nodes.Count == 4 && nodes.Select(n => n.Id).Distinct().Count() == 4); + Check(nodes.Select(n => (n.Flow, n.Fingerprint)).SequenceEqual(new[] + { + ("xtls-rprx-vision", "chrome"), ("", "chrome"), + ("xtls-rprx-vision", "firefox"), ("xtls-rprx-vision-udp443", "chrome") + })); + }); + await test("Subscription presentation and parameter order do not duplicate a transport", () => + { + var first = Link("xtls-rprx-vision", "chrome", "First location"); + var same = Link("xtls-rprx-vision", "chrome", "Renamed location"); + int question = same.IndexOf('?'), fragment = same.IndexOf('#'); + same = same[..(question + 1)] + string.Join('&', same[(question + 1)..fragment].Split('&').Reverse()) + same[fragment..]; + var nodes = Subscription.Parse(first + "\n" + same); + Check(nodes.Count == 1 && nodes[0].Name == "First location"); + Check(Subscription.Parse(same).Single().Id == nodes[0].Id); + }); + await test("Vision UDP443 is preserved in plain and encoded subscriptions and Xray", () => + { + string link = Link("xtls-rprx-vision-udp443", "chrome"); + foreach (string text in new[] { link, Convert.ToBase64String(Encoding.UTF8.GetBytes(link)) }) + { + var node = Subscription.Parse(text).Single(); + Check(node.Flow == "xtls-rprx-vision-udp443"); + var user = XrayConfigBuilder.Build(node)["outbounds"]![0]!["settings"]!["vnext"]![0]!["users"]![0]!; + Check(user["flow"]!.GetValue() == node.Flow); + } + }); + await test("Subscription accepts only the exact supported flow names", () => + { + foreach (string flow in new[] { "xtls-rprx-vision-udp", "xtls-rprx-vision-udp443-extra", "XTLS-rprx-vision-udp443", "xtls-rprx-vision\n" }) + { + try { Subscription.Parse(Link(flow, "chrome")); } + catch (InvalidDataException) { continue; } + throw new InvalidOperationException("Unsupported subscription flow was accepted."); + } + }); + } + + private static string Link(string flow, string fingerprint, string name = "Synthetic location") + { + string uuid = new Guid(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()).ToString(); + string key = Convert.ToBase64String(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return $"vless://{uuid}@8.8.8.8:443?security=reality&type=tcp&encryption=none&pbk={key}&sid=abcd&sni=example.com&fp={fingerprint}&flow={Uri.EscapeDataString(flow)}#{Uri.EscapeDataString(name)}"; + } + + private static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("Subscription transport regression."); + } +} diff --git a/tests/QVPN.Tests/SystemTunnelProbeTests.cs b/tests/QVPN.Tests/SystemTunnelProbeTests.cs new file mode 100644 index 0000000..52129b2 --- /dev/null +++ b/tests/QVPN.Tests/SystemTunnelProbeTests.cs @@ -0,0 +1,45 @@ +using QVPN.Core; + +internal static class SystemTunnelProbeTests +{ + public static async Task RunAsync(Func test) + { + await test("An Up VPN adapter cannot hide a physical IPv4 route", () => + { + var state = SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 9), new(0, 14)); + Check(state.AdapterAvailable && !state.Ipv4RoutedThroughTunnel && !state.Healthy); + }); + await test("IPv6 bypass is rejected even when IPv4 uses the VPN", () => + { + var state = SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 14), new(0, 9)); + Check(state.Ipv4RoutedThroughTunnel && state.Ipv6RoutedThroughTunnel == false && !state.Healthy); + }); + await test("A missing IPv6 route permits a verified IPv4-only network", () => + { + foreach (uint error in new uint[] {1168, 1231}) + { + var state = SystemTunnelProbe.Evaluate(true, 14, 0, new(0, 14), new(error, 0)); + Check(state.Healthy && state.Ipv6RoutedThroughTunnel is null); + } + Check(SystemTunnelProbe.Evaluate(true, 14, 0, new(0, 14), null).Healthy); + }); + await test("Unknown route errors and a missing adapter never report a healthy VPN", () => + { + Check(!SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 14), new(5, 0)).Healthy); + Check(!SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 14), new(232, 0)).Healthy); + Check(!SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 14), new(1232, 0)).Healthy); + Check(!SystemTunnelProbe.Evaluate(true, 14, 14, new(5, 0), new(0, 14)).Healthy); + Check(!SystemTunnelProbe.Evaluate(false, 14, 14, new(0, 14), new(0, 14)).Healthy); + Check(!SystemTunnelProbe.Evaluate(true, 0, 0, new(0, 0), new(0, 0)).Healthy); + }); + await test("Both Windows route families can belong to the verified VPN", () => + { + Check(SystemTunnelProbe.Evaluate(true, 14, 14, new(0, 14), new(0, 14)).Healthy); + }); + } + + private static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("System tunnel route verification failed."); + } +} diff --git a/tests/QVPN.Tests/XrayConfigTests.cs b/tests/QVPN.Tests/XrayConfigTests.cs new file mode 100644 index 0000000..b9f0915 --- /dev/null +++ b/tests/QVPN.Tests/XrayConfigTests.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; +using QVPN.Core; + +public static class XrayConfigTests +{ + public static async Task RunAsync(Func test) + { + await test("Xray accepts only local SOCKS traffic and has no direct fallback", () => + { + var config = XrayConfigBuilder.Build(Fixture()); + var inbound = config["inbounds"]!.AsArray().Single()!; + Check(inbound["protocol"]!.GetValue() == "socks" && inbound["listen"]!.GetValue() == "127.0.0.1"); + Check(inbound["port"]!.GetValue() == Constants.XrayPort); + Check(inbound["settings"]!["auth"]!.GetValue() == "noauth" && inbound["settings"]!["udp"]!.GetValue()); + Check(inbound["settings"]!["ip"]!.GetValue() == "127.0.0.1" && !inbound["sniffing"]!["enabled"]!.GetValue()); + var outbound = config["outbounds"]!.AsArray().Single()!; + Check(outbound["protocol"]!.GetValue() == "vless" && !outbound["mux"]!["enabled"]!.GetValue()); + Check(config["routing"]!["rules"]!.AsArray().Single()!["outboundTag"]!.GetValue() == outbound["tag"]!.GetValue()); + Check(config["dns"] is null && config["log"]!["loglevel"]!.GetValue() == "error"); + }); + await test("Xray retains every VLESS and Reality connection field", () => + { + foreach (var flow in new[] { "xtls-rprx-vision", "" }) + { + var node = Fixture() with { Flow = flow }; + var outbound = XrayConfigBuilder.Build(node)["outbounds"]!.AsArray().Single()!; + var endpoint = outbound["settings"]!["vnext"]!.AsArray().Single()!; + var user = endpoint["users"]!.AsArray().Single()!; + Check(endpoint["address"]!.GetValue() == node.Host && endpoint["port"]!.GetValue() == node.Port); + Check(user["id"]!.GetValue() == node.Uuid && user["flow"]!.GetValue() == node.Flow && user["encryption"]!.GetValue() == "none"); + var stream = outbound["streamSettings"]!; + Check(stream["network"]!.GetValue() == "raw" && stream["security"]!.GetValue() == "reality"); + var reality = stream["realitySettings"]!; + Check(reality["serverName"]!.GetValue() == node.ServerName && reality["fingerprint"]!.GetValue() == node.Fingerprint); + Check(reality["password"]!.GetValue() == node.PublicKey && reality["shortId"]!.GetValue() == node.ShortId); + Check(reality["show"]!.GetValue() == false && reality["spiderX"]!.GetValue() == "/"); + } + }); + await test("Xray optional Windows binding uses the requested adapter and port", () => + { + var bound = XrayConfigBuilder.Build(Fixture(), 17902, "Беспроводная сеть"); + Check(bound["inbounds"]!.AsArray().Single()!["port"]!.GetValue() == 17902); + Check(bound["outbounds"]!.AsArray().Single()!["streamSettings"]!["sockopt"]!["interface"]!.GetValue() == "Беспроводная сеть"); + Check(XrayConfigBuilder.Build(Fixture())["outbounds"]!.AsArray().Single()!["streamSettings"]!["sockopt"] is null); + foreach (var port in new[] { 0, -1, 65536 }) Throws(() => XrayConfigBuilder.Build(Fixture(), port)); + foreach (var name in new[] { "", " ", "adapter\nname", new string('a', 257) }) Throws(() => XrayConfigBuilder.Build(Fixture(), bindInterface: name)); + }); + } + + // Checks the exact packaged engine using synthetic credentials only. Raw + // process output is drained, since future checks may include private data. + public static async Task CheckEngineAsync(string executable, CancellationToken token = default) + { + string directory = Path.Combine(Path.GetTempPath(), "QVPN.XrayConfigTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + foreach (var flow in new[] { "xtls-rprx-vision", "" }) + foreach (var bound in new[] { false, true }) + { + var config = XrayConfigBuilder.Build(Fixture() with { Flow = flow }, 17902, bound ? "Ethernet" : null); + string path = Path.Combine(directory, "synthetic-config.json"); + await File.WriteAllTextAsync(path, config.ToJsonString(), token); + var start = new ProcessStartInfo(Path.GetFullPath(executable)) + { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(executable))! + }; + start.ArgumentList.Add("run"); start.ArgumentList.Add("-test"); start.ArgumentList.Add("-config"); start.ArgumentList.Add(path); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Xray test process did not start."); + var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(TimeSpan.FromSeconds(10)); + try { await process.WaitForExitAsync(limit.Token); } + catch { try { if (!process.HasExited) process.Kill(true); } catch { } throw; } + await Task.WhenAll(stdout, stderr); + Console.WriteLine($"XRAY_CONFIG_CHECK flowVision={flow.Length > 0} binding={bound} exit={process.ExitCode}"); + if (process.ExitCode != 0) return process.ExitCode; + } + return 0; + } + finally + { + string absolute = Path.GetFullPath(directory); + string root = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + if (!absolute.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !Path.GetFileName(absolute).StartsWith("QVPN.XrayConfigTests-", StringComparison.Ordinal)) + throw new InvalidOperationException("Unsafe Xray test cleanup target."); + Directory.Delete(absolute, true); + } + } + + private static VpnNode Fixture() + { + string uuid = new Guid(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()).ToString(); + string key = Convert.ToBase64String(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return new("synthetic", "Synthetic location", "8.8.8.8", 443, uuid, "example.com", key, "abcd", "chrome", "xtls-rprx-vision"); + } + + private static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("Xray configuration regression."); + } + + private static void Throws(Action action) where T : Exception + { + try { action(); } catch (T) { return; } + throw new InvalidOperationException("Xray configuration validation regression."); + } +}