Release QVPN 1.0.5 with Xray and resilient tunnel recovery

Integrate Xray 26.9.9 with the sing-box system tunnel and verify Windows routes during health checks.

Improve endpoint selection, network recovery, subscription refresh deadlines, diagnostics, packaging, and regression coverage.
This commit is contained in:
Курнат Андрей
2026-10-30 06:15:08 +03:00
parent ef622e6229
commit aae6dd28dd
34 changed files with 2603 additions and 120 deletions
+4
View File
@@ -4,6 +4,9 @@ dist/
tools/core*/ tools/core*/
tools/core.zip tools/core.zip
tools/sing-box-*-source.zip tools/sing-box-*-source.zip
tools/xray-unpacked/
tools/Xray-*.zip
tools/Xray-*.zip.dgst
research/*.apk research/*.apk
research/phone*.xml research/phone*.xml
research/phone*.png research/phone*.png
@@ -14,6 +17,7 @@ src/QVPN.App/*smoke.json
research/*.png research/*.png
research/phone-package.txt research/phone-package.txt
research/*verification.json research/*verification.json
research/*verification-*.json
research/service-regression.json research/service-regression.json
research/recovery-socks.json research/recovery-socks.json
research/disconnect-write-failure.json research/disconnect-write-failure.json
+1 -1
View File
@@ -1 +1 @@
<Project><PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><LangVersion>latest</LangVersion><Version>1.0.1</Version><EnableWindowsTargeting>true</EnableWindowsTargeting><Deterministic>true</Deterministic></PropertyGroup></Project> <Project><PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><LangVersion>latest</LangVersion><Version>1.0.5</Version><EnableWindowsTargeting>true</EnableWindowsTargeting><Deterministic>true</Deterministic></PropertyGroup></Project>
+17 -7
View File
@@ -1,10 +1,10 @@
# QVPN для Windows # 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 в поле ключа и нажмите «Добавить ключ». 2. Откройте ярлык QVPN. Вставьте ссылку BlancVPN в поле ключа и нажмите «Добавить ключ».
3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить». 3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить».
@@ -19,9 +19,15 @@
- Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна. - Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна.
- Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа. - Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа.
- Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя. - Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя.
- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд. - Желание оставаться подключённым и последняя рабочая локация сохраняются на диск атомарно. Сбой локального процесса повторяет подключение к рабочему серверу. Недоступные локации переключаются без минутных пауз; задержка от 2 до 60 секунд применяется при повторных локальных сбоях или после полного неудачного круга серверов.
- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации. - HTTPS-проверки выполняются новыми соединениями параллельно с общим пределом 6 секунд. В системном режиме нужны поднятый адаптер QVPN и ответ 204 по двум путям: через локальный VPN-прокси и через обычный DNS/HTTPS Windows. Рабочий прокси больше не скрывает отказ системного туннеля. Повторный отказ только системного пути восстанавливает ту же локацию; отказ прокси включает смену сервера. Разовая ошибка не разрывает рабочий туннель; три подряд приводят к восстановлению, при первом подключении недоступный сервер пропускается после двух проверок. Проверки не блокируют команды интерфейса.
- Возврат из сна и появление сети восстанавливают VPN. Обновление подписки раз в 6 часов сохраняет рабочее соединение, если его параметры не изменились. - Возврат из сна, смена физической сети и её появление сбрасывают старые задержки восстановления. Последняя рабочая локация предпочтительнее случайного нового сервера. 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-процессы при её завершении. - Windows автоматически перезапускает аварийно завершившуюся службу. Job Object закрывает принадлежащие ей VPN-процессы при её завершении.
- Журнал ограничен по размеру и содержит события подключения, без ключа, UUID и истории посещений. - Журнал ограничен по размеру и содержит события подключения, без ключа, 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`. Тесты: `dotnet run --project tests\QVPN.Tests -c Release`.
Проверка установленного активного VPN с правами администратора: `pwsh -File scripts\verify-stability-installed.ps1 -InstallUpdate`. Сохраняет резервную копию зашифрованных настроек, обновляет программу, проверяет реальный TUN/HTTPS, TCP-замеры и обновление подписки без разрыва, сбой ядра и службы с возвратом к прежнему серверу, затем наблюдает соединение 5 минут. Временный разрыв при испытании сбоев ожидаем. Публичный отчёт содержит только результаты, время и счётчики.
Диагностика без изменения системных маршрутов: Диагностика без изменения системных маршрутов:
```powershell ```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. Не запускайте тестовую и установленную службу одновременно. Тестовый режим доступен только при ручном консольном запуске; обычная служба всегда создаёт системный 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`. Неизменённый 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 и закрытый код мобильного приложения в поставку не включены. QVPN — личное независимое приложение, не официальный продукт BlancVPN. Брендовые изображения, APK и закрытый код мобильного приложения в поставку не включены.
+26
View File
@@ -81,3 +81,29 @@ Upstream `LICENSE` для точного тега разрешает sing-box п
Версия 1.0.1 убирает отложенный запуск службы. При отсутствии ответа интерфейс проверяет состояние в диспетчере служб и автоматически запускает остановленную службу. Установщик добавляет владельцу права START и QUERY_STATUS с сохранением остальных ACL; изменения конфигурации службы и остановка этим разрешением не предоставляются. Сообщение об установке появляется только при подтверждённом отсутствии регистрации службы (код Windows 1060). Ожидание запуска, отключённая служба и отказ доступа показываются отдельно. Обновление сначала закрывает старое окно, затем останавливает службу, чтобы интерфейс не запускал её во время замены файлов. Версия 1.0.1 убирает отложенный запуск службы. При отсутствии ответа интерфейс проверяет состояние в диспетчере служб и автоматически запускает остановленную службу. Установщик добавляет владельцу права START и QUERY_STATUS с сохранением остальных ACL; изменения конфигурации службы и остановка этим разрешением не предоставляются. Сообщение об установке появляется только при подтверждённом отсутствии регистрации службы (код Windows 1060). Ожидание запуска, отключённая служба и отказ доступа показываются отдельно. Обновление сначала закрывает старое окно, затем останавливает службу, чтобы интерфейс не запускал её во время замены файлов.
Сборка 1.0.1 и 16 групп тестов прошли. `scripts/verify-startup-installed.ps1 -InstallUpdate` обновляет программу с сохранением подписки, проверяет отсутствие отложенного старта, останавливает службу и запускает обычное окно без повышенных прав. Успешный результат требует повторного подключения с прежним числом локаций и HTTPS 204 через системный TUN. JSON и отрисовки проверки исключены из Git. Этот сценарий проверяет восстановление после остановки службы; реальную перезагрузку компьютера он не выполняет. Сборка 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 успешно. Многосуточная работа и реальный сон/пробуждение в этой проверке не испытаны.
+88
View File
@@ -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()`. Поэтому в этой версии изменены проверенные дефекты управления существующим туннелем. Многодневная работа и реальный сон/пробуждение этим исследованием не доказаны.
+28
View File
@@ -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."
}
+14
View File
@@ -32,9 +32,13 @@ function Invoke-DotNet([string[]]$Arguments) {
$coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64' $coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64'
$rulesRoot = Join-Path $projectRoot 'tools/rules' $rulesRoot = Join-Path $projectRoot 'tools/rules'
$xrayRoot = Join-Path $projectRoot 'tools/xray-unpacked'
foreach ($coreFile in @('sing-box.exe', 'libcronet.dll', 'LICENSE')) { 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" } 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)) { 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.' 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')) 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 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 $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') 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 $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 $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') } 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. 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. 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 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 Source tree and module/build instructions: https://github.com/SagerNet/sing-box/tree/v1.14.2
+197
View File
@@ -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
}
+17
View File
@@ -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).'
+150
View File
@@ -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
}
@@ -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
}
+72
View File
@@ -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
}
+2 -1
View File
@@ -327,7 +327,7 @@ public partial class MainWindow : Window
private string DiagnosticsReport() 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($"Время: {DateTimeOffset.Now:O}");
report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}"); report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}");
if (_availability is { } availability) if (_availability is { } availability)
@@ -338,6 +338,7 @@ public partial class MainWindow : Window
report.AppendLine($"VPN запрошен: {status.DesiredConnected}"); report.AppendLine($"VPN запрошен: {status.DesiredConnected}");
report.AppendLine($"Сообщение: {status.Message}"); report.AppendLine($"Сообщение: {status.Message}");
report.AppendLine($"Серверов: {status.Nodes.Count}; перезапусков: {status.Restarts}; PID ядра: {status.EnginePid}"); 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($"Российские сайты напрямую: {status.RussianDirect}; автопереход: {status.AutoFailover}");
report.AppendLine("\nСобытия службы:"); report.AppendLine("\nСобытия службы:");
foreach (var entry in status.Events) report.AppendLine(entry); foreach (var entry in status.Events) report.AppendLine(entry);
+20 -10
View File
@@ -5,6 +5,25 @@ namespace QVPN.Core;
public static class ConfigBuilder public static class ConfigBuilder
{ {
public static JsonObject Build(VpnNode node, bool tun, bool russianDirect, string rulesDirectory, int port = Constants.ProxyPort) 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 var root = new JsonObject
{ {
@@ -15,16 +34,7 @@ public static class ConfigBuilder
["final"] = "remote-dns", ["strategy"] = "prefer_ipv4" ["final"] = "remote-dns", ["strategy"] = "prefer_ipv4"
}, },
["inbounds"] = new JsonArray(new JsonObject { ["type"] = "mixed", ["tag"] = "health-in", ["listen"] = "127.0.0.1", ["listen_port"] = port }), ["inbounds"] = new JsonArray(new JsonObject { ["type"] = "mixed", ["tag"] = "health-in", ["listen"] = "127.0.0.1", ["listen_port"] = port }),
["outbounds"] = new JsonArray(new JsonObject ["outbounds"] = new JsonArray(proxy, new JsonObject { ["type"] = "direct", ["tag"] = "direct" })
{
["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" })
}; };
if (tun) ((JsonArray)root["inbounds"]!).Add(new JsonObject if (tun) ((JsonArray)root["inbounds"]!).Add(new JsonObject
{ {
+185
View File
@@ -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<int?> MeasureAsync(VpnNode node, CancellationToken token = default)
=> (await FindPathAsync(node, false, token))?.LatencyMs;
/// <summary>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.</summary>
/// <remarks>TCP probes are hints. Multiple unverified adapters cannot be
/// disambiguated by enumeration order; cancellation never selects a fallback.</remarks>
public static async Task<string?> SelectInterfaceAsync(VpnNode node, CancellationToken token = default)
=> (await FindPathAsync(node, true, token))?.InterfaceName;
private static async Task<PathResult?> 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<PathResult?> 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<PhysicalPath> GetPaths()
{
var paths = new List<PhysicalPath>();
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);
}
+135
View File
@@ -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;
}
/// <summary>Checks new VPN connections and, in TUN mode, the Windows DNS/HTTPS path.</summary>
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;
/// <summary>The injected client remains owned by its caller; useful for deterministic transport tests.</summary>
public HealthProbe(HttpClient http, IEnumerable<Uri>? 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<bool> CheckAsync(CancellationToken token = default) =>
(await CheckPathsAsync(false, token)).Healthy;
public async Task<HealthProbeResult> 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<bool> 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<bool> 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(); }
}
}
+4 -1
View File
@@ -9,6 +9,7 @@ public static class Constants
public const string ServiceName = "QVPNService"; public const string ServiceName = "QVPNService";
public const string PipeName = "QVPN.Control.v1"; public const string PipeName = "QVPN.Control.v1";
public const int ProxyPort = 17891; public const int ProxyPort = 17891;
public const int XrayPort = 17892;
public static string DataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN"); public static string DataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN");
public static JsonSerializerOptions Json { get; } = new() { PropertyNameCaseInsensitive = true, WriteIndented = false }; 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 RussianDirect { get; set; } = true;
public bool AutoFailover { get; set; } = true; public bool AutoFailover { get; set; } = true;
public string SelectedId { get; set; } = ""; public string SelectedId { get; set; } = "";
public string LastWorkingId { get; set; } = "";
public DateTimeOffset? UpdatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; }
public string? SubscriptionInfo { get; set; } public string? SubscriptionInfo { get; set; }
} }
public record Request(string Command, string? Value = null, bool? Enabled = null); 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, 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<NodeView> Nodes, List<string> Events); int Restarts, int? EnginePid, bool RussianDirect, bool AutoFailover, string SelectedId, DateTimeOffset? UpdatedAt, List<NodeView> Nodes, List<string> Events,
int? XrayPid = null, string? VpnCore = null, bool? SystemRoutesVerified = null);
public record Response(bool Ok, string? Error, Status? Status); public record Response(bool Ok, string? Error, Status? Status);
public sealed class SecureStore(string directory) public sealed class SecureStore(string directory)
+82
View File
@@ -0,0 +1,82 @@
using System.Text;
namespace QVPN.Core;
public static class NodeSelection
{
/// <summary>Maps a saved location to a refreshed subscription without guessing between duplicate locations.</summary>
public static VpnNode? FindReplacement(VpnNode oldNode, IReadOnlyList<VpnNode> 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<string> RemapFailedConnections(IReadOnlyList<VpnNode> oldNodes, IReadOnlyList<VpnNode> newNodes,
IReadOnlySet<string> 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<VpnNode> nodes, string selectedId, bool autoFailover,
string? lastWorkingId, IReadOnlySet<string> failed, IReadOnlyDictionary<string, int?> 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;
}
}
+8 -2
View File
@@ -5,10 +5,16 @@ public sealed class RecoveryPolicy
public int ConsecutiveFailures { get; private set; } public int ConsecutiveFailures { get; private set; }
public int Attempts { get; private set; } public int Attempts { get; private set; }
public DateTimeOffset NextAttempt { 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 Healthy() { ConsecutiveFailures = 0; }
public void Stable() { Healthy(); Attempts = 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 void Reset() { Stable(); NextAttempt = DateTimeOffset.MinValue; }
public bool CanAttempt(DateTimeOffset now) => now >= NextAttempt; public bool CanAttempt(DateTimeOffset now) => now >= NextAttempt;
} }
+39 -3
View File
@@ -1,8 +1,11 @@
using System.Net; using System.Net;
using System.Runtime.CompilerServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
[assembly: InternalsVisibleTo("QVPN.Tests")]
namespace QVPN.Core; namespace QVPN.Core;
public static partial class Subscription public static partial class Subscription
@@ -31,8 +34,38 @@ public static partial class Subscription
{ {
var uri = ValidateUrl(url); var uri = ValidateUrl(url);
using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false }; 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"); 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<VpnNode> 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<VpnNode> 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); 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 is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new InvalidDataException("BlancVPN отклонил ключ. Проверьте подписку.");
if (response.StatusCode == HttpStatusCode.NotFound) 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(); using var buffer = new MemoryStream();
byte[] chunk = new byte[8192]; int n; 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); } 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); return (Parse(Encoding.UTF8.GetString(buffer.ToArray())), response.Headers.TryGetValues("subscription-userinfo", out var info) ? info.FirstOrDefault() : null);
} }
public static List<VpnNode> Parse(string text) public static List<VpnNode> 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."); 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"); 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 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 в подписке."); || !(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 в подписке.");
string id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{uuid}|{host}|{u.Port}|{sni}|{pub}|{sid}")))[..20]; // 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('#')); string name = Uri.UnescapeDataString(u.Fragment.TrimStart('#'));
name = new string(name.Where(c => !char.IsControl(c)).Take(160).ToArray()); name = new string(name.Where(c => !char.IsControl(c)).Take(160).ToArray());
if (name.Length == 0) name = "Локация " + (nodes.Count + 1); if (name.Length == 0) name = "Локация " + (nodes.Count + 1);
@@ -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;
}
+64
View File
@@ -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;
}
/// <summary>Verifies that Windows still routes public control destinations into QVPN.</summary>
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);
}
+71
View File
@@ -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"
})
}
};
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ if (args.Contains("--console"))
Directory.CreateDirectory(path); Directory.CreateDirectory(path);
using var stop = new CancellationTokenSource(); using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); }; 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); 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.")); Console.WriteLine("QVPN diagnostic console. " + (args.Contains("--socks-only") ? "SOCKS test only; system routing unchanged." : "TUN enabled."));
try try
+288 -88
View File
@@ -1,8 +1,8 @@
using QVPN.Core; using QVPN.Core;
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets; using System.Net.Sockets;
using System.Net.NetworkInformation;
using System.Text.Json; using System.Text.Json;
namespace QVPN.Service; namespace QVPN.Service;
@@ -11,30 +11,44 @@ public sealed class VpnController : IAsyncDisposable
{ {
private readonly SemaphoreSlim gate = new(1); private readonly SemaphoreSlim gate = new(1);
private readonly SecureStore store; private readonly SecureStore store;
private readonly string directory, corePath, rulesPath; private readonly string directory, corePath, xrayPath, rulesPath;
private readonly bool socksOnly; private readonly bool socksOnly;
private Settings settings; private Settings settings;
private readonly RecoveryPolicy recovery = new(); 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<string> events = []; private readonly List<string> events = [];
private readonly Dictionary<string, int?> latencies = []; private readonly Dictionary<string, int?> latencies = [];
private Process? engine; private Process? engine;
private Process? xray;
private ProcessJob? job; private ProcessJob? job;
private VpnNode? active; private VpnNode? active;
private string state = "disconnected", message = "VPN отключён"; private string state = "disconnected", message = "VPN отключён";
private DateTimeOffset? connectedSince, startedAt; private DateTimeOffset? connectedSince, healthySince;
private DateTimeOffset healthDue, refreshDue = DateTimeOffset.UtcNow.AddHours(6); 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 Status snapshot = new("disconnected", "Служба запускается", false, null, null, null, 0, null, true, true, "", null, [], []);
private readonly HashSet<string> failed = []; private readonly HashSet<string> failed = [];
private Task? measureTask; private Task? measureTask;
private Task<HealthProbeResult>? healthTask;
private CancellationTokenSource? healthStop;
private Task<(List<VpnNode> 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 readonly CancellationTokenSource measurementStop = new();
private bool disposed; private bool disposed;
private bool? systemRoutesVerified;
private string RuntimePath => Path.Combine(directory, "runtime.json"); 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); this.directory = directory; this.socksOnly = socksOnly; store = new(directory);
corePath = overrideCore ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "core", "sing-box.exe")); 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")); rulesPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(corePath)!, "..", "rules"));
try { settings = store.Load(); } try { settings = store.Load(); }
catch { settings = new(); Event("Не удалось прочитать настройки. Добавьте ключ заново; исходный файл сохранён."); } catch { settings = new(); Event("Не удалось прочитать настройки. Добавьте ключ заново; исходный файл сохранён."); }
@@ -46,7 +60,8 @@ public sealed class VpnController : IAsyncDisposable
Event("Служба QVPN запущена" + (socksOnly ? " (диагностика SOCKS)" : "")); Publish(); Event("Служба QVPN запущена" + (socksOnly ? " (диагностика SOCKS)" : "")); Publish();
} }
public Status GetStatus() => Volatile.Read(ref snapshot); 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) private void Event(string text)
{ {
lock (events) lock (events)
@@ -68,70 +83,103 @@ public sealed class VpnController : IAsyncDisposable
List<string> log; lock (events) log = events.TakeLast(60).ToList(); List<string> log; lock (events) log = events.TakeLast(60).ToList();
Volatile.Write(ref snapshot, new(state, message, settings.DesiredConnected, active?.Id, active?.Name, connectedSince, restarts, 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, 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<Response> ExecuteAsync(Request request, CancellationToken token) public async Task<Response> ExecuteAsync(Request request, CancellationToken token)
{ {
if (request.Command == "status") return new(true, null, GetStatus()); 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); await gate.WaitAsync(token);
try try
{ {
switch (request.Command) 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": case "connect":
if (settings.Nodes.Count == 0) throw new ArgumentException("Сначала добавьте ключ BlancVPN."); if (settings.Nodes.Count == 0) throw new ArgumentException("Сначала добавьте ключ BlancVPN.");
if (!socksOnly && HasOtherVpn()) throw new ArgumentException("Отключите HAPP перед подключением QVPN. Одновременные VPN конфликтуют."); 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": case "disconnect":
settings.DesiredConnected = false; settings.DesiredConnected = false;
connectionRefreshPending = false; explicitConnectionRefreshPending = false;
try { store.Save(settings); } try { store.Save(settings); }
finally { await StopEngineAsync(); recovery.Reset(); failed.Clear(); State("disconnected", "VPN отключён"); } finally { await StopEngineAsync(); recovery.Reset(); failed.Clear(); State("disconnected", "VPN отключён"); }
break; break;
case "select": case "select":
if (!string.IsNullOrEmpty(request.Value) && settings.Nodes.All(n => n.Id != request.Value)) throw new ArgumentException("Локация не найдена. Обновите подписку."); 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(); Persist(s => s.SelectedId = request.Value ?? ""); failed.Clear(); recovery.Reset(); await StopEngineAsync();
if (settings.DesiredConnected) State("connecting", "Подключаемся к выбранной локации…"); break; if (settings.DesiredConnected) State("connecting", "Подключаемся к выбранной локации…"); break;
case "russian": case "russian":
if ((request.Enabled ?? true) == settings.RussianDirect) break;
Persist(s => s.RussianDirect = request.Enabled ?? true); await StopEngineAsync(); recovery.Reset(); Persist(s => s.RussianDirect = request.Enabled ?? true); await StopEngineAsync(); recovery.Reset();
if (settings.DesiredConnected) State("connecting", "Применяем маршрутизацию…"); break; if (settings.DesiredConnected) State("connecting", "Применяем маршрутизацию…"); break;
case "failover": Persist(s => s.AutoFailover = request.Enabled ?? true); break; case "failover": Persist(s => s.AutoFailover = request.Enabled ?? true); break;
case "measure": case "measure":
if (Interlocked.CompareExchange(ref measuring, 1, 0) == 0) BeginMeasurements();
{
var nodes = settings.Nodes.ToArray(); measureTask = Task.Run(() => MeasureAsync(nodes)); Event("Измеряем задержку локаций…");
}
break; break;
default: throw new ArgumentException("Неизвестная команда QVPN."); default: throw new ArgumentException("Неизвестная команда QVPN.");
} }
Publish(); return new(true, null, GetStatus()); Publish(); return new(true, null, GetStatus());
} }
finally { Publish(); gate.Release(); } finally { Publish(); gate.Release(); Wake(); }
} }
private void Persist(Action<Settings> change) private void Persist(Action<Settings> change)
{ {
var updated = JsonSerializer.Deserialize<Settings>(JsonSerializer.Serialize(settings, Constants.Json), Constants.Json)!; var updated = JsonSerializer.Deserialize<Settings>(JsonSerializer.Serialize(settings, Constants.Json), Constants.Json)!;
change(updated); store.Save(updated); settings = updated; change(updated); store.Save(updated); settings = updated;
} }
private async Task RefreshAsync(string url, CancellationToken token) private async Task ApplySubscriptionAsync(string url, (List<VpnNode> 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. // Commit only a complete, validated subscription. Keep the connected process if its credential is unchanged.
var old = active; 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 => Persist(s =>
{ {
s.SubscriptionUrl = url; s.Nodes = result.Nodes; s.SubscriptionInfo = result.Info; s.UpdatedAt = DateTimeOffset.UtcNow; 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); refreshDue = DateTimeOffset.UtcNow.AddHours(6);
Event("Подписка обновлена: " + result.Nodes.Count + " локаций."); 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) 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) + " Повторяем подключение автоматически."); await StopEngineAsync(); recovery.Failed(DateTimeOffset.UtcNow); State("recovering", SafeError.Describe(e) + " Повторяем подключение автоматически.");
} }
finally { Publish(); gate.Release(); } 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(); } } 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) private async Task TickAsync(CancellationToken token)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
await CompleteBackgroundRefreshAsync(token);
if (!settings.DesiredConnected) return; if (!settings.DesiredConnected) return;
if (!PhysicalNetworkAvailable()) { await StopEngineAsync(); State("waiting-network", "Ожидаем доступную сеть. Подключение восстановится автоматически."); return; } var network = DirectLatencyProbe.GetNetworkState();
if (!socksOnly && HasOtherVpn()) { await StopEngineAsync(); State("waiting-network", "Отключите HAPP: QVPN ожидает освобождения VPN-маршрута."); return; } if (!network.Available) { waitingNetwork = true; await StopEngineAsync(); State("waiting-network", "Ожидаем доступную сеть. Подключение восстановится автоматически."); return; }
if (Interlocked.Exchange(ref resumeRequested, 0) != 0) { await StopEngineAsync(); recovery.Reset(); State("recovering", "Восстанавливаем VPN после сна…"); } if (!socksOnly && HasOtherVpn()) { waitingNetwork = true; await StopEngineAsync(); State("waiting-network", "Отключите HAPP: QVPN ожидает освобождения VPN-маршрута."); return; }
if (now >= refreshDue && settings.SubscriptionUrl.Length > 0) bool resumed = Interlocked.Exchange(ref resumeRequested, 0) != 0;
if (waitingNetwork || networkPath != network.PathKey || resumed)
{ {
try { await RefreshAsync(settings.SubscriptionUrl, token); } bool changed = networkPath != null;
catch (Exception e) when (e is not OperationCanceledException || !token.IsCancellationRequested) { Event(SafeError.Describe(e)); refreshDue = now.AddMinutes(15); } 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 (engine == null)
{ {
if (refreshBeforeStart && refreshTask != null)
{
State("connecting", "Обновляем параметры подключения…");
return;
}
if (!recovery.CanAttempt(now)) return; if (!recovery.CanAttempt(now)) return;
active = PickNode(); active = PickNode();
if (active == null && settings.Nodes.Count > 0) { failed.Clear(); active = PickNode(); }
if (active == null) { State("error", "Нет доступных локаций. Обновите ключ."); return; } if (active == null) { State("error", "Нет доступных локаций. Обновите ключ."); return; }
State("connecting", "Подключаемся: " + active.Name); Publish(); State("connecting", "Подключаемся: " + active.Name); Publish();
try { await StartEngineAsync(active, token); } 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); systemRoutesVerified = SystemTunnelProbe.Check().Healthy;
if (await HealthyAsync(token)) 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; 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 подключён. Соединение проверено."); 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 State("recovering", "Проверяем соединение… Автовосстановление включено."); else
{
healthDue = now.AddSeconds(2);
State("connecting", result.SystemPathFailed || systemRoutesVerified == false ? "Проверяем системные маршруты, туннель и DNS…" : "Повторяем проверку связи…");
} }
} }
}
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() private VpnNode? PickNode()
{ {
if (settings.Nodes.Count == 0) return null; return NodeSelection.Choose(settings.Nodes, settings.SelectedId, settings.AutoFailover, settings.LastWorkingId, failed, latencies);
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];
} }
private async Task FailedEngineAsync(DateTimeOffset now) private async Task FailedEngineAsync(bool endpointFailure)
{ {
if (active != null && settings.AutoFailover) failed.Add(active.Id); if (endpointFailure && active != null && settings.AutoFailover) failed.Add(active.Id);
await StopEngineAsync(); restarts++; recovery.Failed(now); State("recovering", "Восстанавливаем соединение автоматически…"); 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) private async Task StartEngineAsync(VpnNode node, CancellationToken token)
{ {
if (!File.Exists(corePath)) throw new InvalidDataException("Не найден VPN-модуль. Переустановите QVPN."); if (!File.Exists(corePath) || !File.Exists(xrayPath)) throw new InvalidDataException("Не найден VPN-модуль. Переустановите QVPN.");
await File.WriteAllTextAsync(RuntimePath, ConfigBuilder.Build(node, !socksOnly, settings.RussianDirect, rulesPath).ToJsonString(), token); string? physicalInterface = await DirectLatencyProbe.SelectInterfaceAsync(node, token);
var checkInfo = new ProcessStartInfo(corePath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(corePath)! }; if (physicalInterface == null) throw new EndpointUnavailableException();
checkInfo.ArgumentList.Add("check"); checkInfo.ArgumentList.Add("-c"); checkInfo.ArgumentList.Add(RuntimePath); await File.WriteAllTextAsync(XrayRuntimePath, XrayConfigBuilder.Build(node, Constants.XrayPort, physicalInterface).ToJsonString(), token);
using (var check = Process.Start(checkInfo) ?? throw new InvalidOperationException()) 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))
{ {
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); }
}
}
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); var stderr = check.StandardError.ReadToEndAsync(token); var stdout = check.StandardOutput.ReadToEndAsync(token);
using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(15000); using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(TimeSpan.FromSeconds(10));
try { await check.WaitForExitAsync(limit.Token); await Task.WhenAll(stderr, stdout); } try { await check.WaitForExitAsync(limit.Token); await Task.WhenAll(stderr, stdout); }
catch { try { check.Kill(true); } catch { } throw; } catch { try { check.Kill(true); } catch { } throw; }
if (check.ExitCode != 0) throw new InvalidDataException("VPN-модуль отклонил конфигурацию. Переустановите QVPN."); if (check.ExitCode != 0) throw new InvalidDataException("VPN-модуль отклонил конфигурацию. Проверьте установку QVPN.");
} }
var start = new ProcessStartInfo(corePath) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(corePath)! }; private static ProcessStartInfo ProcessInfo(string executable, string[] arguments)
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(); var info = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, WorkingDirectory = Path.GetDirectoryName(executable)! };
try { job.Add(engine); } catch { try { engine.Kill(true); } catch { } engine.Dispose(); engine = null; job.Dispose(); job = null; throw; } 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. // Never persist raw engine output: it can contain destinations and credentials.
_ = DrainAsync(engine.StandardError); _ = DrainAsync(engine.StandardOutput); _ = DrainAsync(process.StandardError); _ = DrainAsync(process.StandardOutput);
startedAt = DateTimeOffset.UtcNow; connectedSince = null; healthDue = startedAt.Value.AddSeconds(3); process.EnableRaisingEvents = true; process.Exited += (_, _) => Wake();
Event("VPN-процесс запущен: " + node.Name); 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 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() 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; job?.Dispose(); job = null;
if (process != null) foreach (var process in processes.OfType<Process>())
{ {
try { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } catch { } finally { process.Dispose(); } 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 { } try { if (File.Exists(RuntimePath)) File.Delete(RuntimePath); } catch { }
} try { if (File.Exists(XrayRuntimePath)) File.Delete(XrayRuntimePath); } catch { }
private async Task<bool> 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;
} }
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 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) private void BeginMeasurements()
&& !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) 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 try
{ {
using var concurrency = new SemaphoreSlim(6); using var concurrency = new SemaphoreSlim(12);
var results = await Task.WhenAll(nodes.Select(async node => var results = await Task.WhenAll(nodes.Select(async node =>
{ {
await concurrency.WaitAsync(measurementStop.Token); await concurrency.WaitAsync(measurementStop.Token);
try try
{ {
using var tcp = new TcpClient(); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(measurementStop.Token); timeout.CancelAfter(TimeSpan.FromSeconds(3)); return (node.Id, Latency: await DirectLatencyProbe.MeasureAsync(node, measurementStop.Token));
var watch = Stopwatch.StartNew(); await tcp.ConnectAsync(node.Host, node.Port, timeout.Token);
return (node.Id, Latency: (int?)Math.Max(1, watch.ElapsedMilliseconds));
} }
catch { return (node.Id, Latency: (int?)null); } catch { return (node.Id, Latency: (int?)null); }
finally { concurrency.Release(); } finally { concurrency.Release(); }
})); }));
await gate.WaitAsync(measurementStop.Token); 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 { 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() public async ValueTask DisposeAsync()
{ {
if (disposed) return; disposed = true; if (disposed) return; disposed = true;
measurementStop.Cancel(); 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 { } if (measureTask != null) try { await measureTask.WaitAsync(TimeSpan.FromSeconds(5)); } catch { }
healthProbe.Dispose();
} }
} }
+3 -3
View File
@@ -60,7 +60,7 @@ internal sealed class Installer(Action<string> progress)
key.SetValue("OwnerSid", owner.Value); key.SetValue("OwnerSid", owner.Value);
key.SetValue("InstallPath", InstallRoot); key.SetValue("InstallPath", InstallRoot);
key.SetValue("DataPath", DataRoot); key.SetValue("DataPath", DataRoot);
key.SetValue("Version", "1.0.1"); key.SetValue("Version", typeof(Installer).Assembly.GetName().Version!.ToString(3));
} }
progress("Настройка автоматического запуска и восстановления службы…"); progress("Настройка автоматического запуска и восстановления службы…");
ConfigureService(previousService); ConfigureService(previousService);
@@ -155,7 +155,7 @@ internal sealed class Installer(Action<string> progress)
private static void VerifyBundle(string root) 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) foreach (string relative in required)
if (!File.Exists(Path.Combine(root, relative))) throw new InvalidDataException("В установщике отсутствует " + relative); 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) 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<string> progress)
{ {
using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true); using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true);
string command = "\"" + Path.Combine(InstallRoot, "QVPN-Setup.exe") + "\" --uninstall"; 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("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot);
key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe")); key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe"));
key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent"); key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent");
+349
View File
@@ -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<HttpResponseMessage>(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<Task>();
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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token);
}
}
+172
View File
@@ -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<string, Action, Task> 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<string>(),
new Dictionary<string, int?> { [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<string> { First.Id });
Check(failed.SetEquals([rejected.Id]));
Check(NodeSelection.Choose([rejected, fresh], rejected.Id, true, rejected.Id, failed, new Dictionary<string, int?>()) == 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<string> { 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<string> { Second.Id };
var latencies = new Dictionary<string, int?> { [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<string> { First.Id }, new Dictionary<string, int?> { [Third.Id] = 1 }) == Second);
Check(NodeSelection.Choose(Nodes, "missing", false, "removed", new HashSet<string>(), new Dictionary<string, int?>()) == First);
});
await test("Automatic switching tries every fresh location before any failed location", () =>
{
var failed = new HashSet<string> { First.Id, Second.Id };
var latencies = new Dictionary<string, int?> { [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<string>(),
new Dictionary<string, int?> { [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<string, int?> { [First.Id] = 1, [Second.Id] = 80, [Third.Id] = 2 };
Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet<string>(), latencies) == Second);
Check(NodeSelection.Choose([Third, First, Second], "", true, Second.Id, new HashSet<string>(), latencies) == Second);
Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet<string> { Second.Id }, latencies) == First);
});
await test("Fresh reachable locations rank by latency ahead of missing and negative measurements", () =>
{
var latencies = new Dictionary<string, int?> { [First.Id] = null, [Second.Id] = 3000 };
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string>(), latencies) == Second);
latencies[Third.Id] = 20;
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string>(), 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<string>(),
new Dictionary<string, int?> { [First.Id] = null, [Third.Id] = null }) == First);
Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet<string> { First.Id },
new Dictionary<string, int?> { [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<string>(), latencies) == First);
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string> { First.Id }, latencies) == Second);
Check(NodeSelection.Choose(Nodes, "", true, Third.Id, new HashSet<string>(), 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<string>(), latencies) == First);
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string> { First.Id }, latencies) == Second);
Check(NodeSelection.Choose([], "", true, null, new HashSet<string>(), 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.");
}
}
+47
View File
@@ -137,6 +137,49 @@ static class Runner
Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed()); Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed());
Check(p.ConsecutiveFailures == 3); p.Healthy(); Check(p.ConsecutiveFailures == 0); 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<string>() == "proxy")!;
Check(proxy["type"]!.GetValue<string>() == "socks" && proxy["server"]!.GetValue<string>() == "127.0.0.1");
Check(proxy["server_port"]!.GetValue<int>() == Constants.XrayPort && proxy["version"]!.GetValue<string>() == "5");
Check(!config.ToJsonString().Contains(FakeUuid) && !config.ToJsonString().Contains("public_key"));
Check(config["inbounds"]!.AsArray().Any(n => n?["type"]?.GetValue<string>() == "tun"));
Check(config["dns"]!["servers"]!.AsArray().Single()!["detour"]!.GetValue<string>() == "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", () => await Test("Recovery backoff bounded; one healthy probe does not clear attempts", () =>
{ {
var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow; var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow;
@@ -156,6 +199,10 @@ static class Runner
{ {
try 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") if (args.Length == 3 && args[0] == "--malformed-service" && args[1] == "--evidence-file")
return await MalformedServiceAsync(args[2]); 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")) if (args.Length is 3 or 4 && args[0] == "--download" && args[1] == "--value-file" && (args.Length == 3 || args[3] == "--diagnose"))
@@ -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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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<int> ReadAsync(Memory<byte> 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();
}
}
@@ -0,0 +1,61 @@
using QVPN.Core;
internal static class SubscriptionRefreshPolicyTests
{
public static async Task RunAsync(Func<string, Action, Task> 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.");
}
}
@@ -0,0 +1,63 @@
using System.Text;
using QVPN.Core;
public static class SubscriptionTransportTests
{
public static async Task RunAsync(Func<string, Action, Task> 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<string>() == 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.");
}
}
@@ -0,0 +1,45 @@
using QVPN.Core;
internal static class SystemTunnelProbeTests
{
public static async Task RunAsync(Func<string, Action, Task> 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.");
}
}
+109
View File
@@ -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<string, Action, Task> 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<string>() == "socks" && inbound["listen"]!.GetValue<string>() == "127.0.0.1");
Check(inbound["port"]!.GetValue<int>() == Constants.XrayPort);
Check(inbound["settings"]!["auth"]!.GetValue<string>() == "noauth" && inbound["settings"]!["udp"]!.GetValue<bool>());
Check(inbound["settings"]!["ip"]!.GetValue<string>() == "127.0.0.1" && !inbound["sniffing"]!["enabled"]!.GetValue<bool>());
var outbound = config["outbounds"]!.AsArray().Single()!;
Check(outbound["protocol"]!.GetValue<string>() == "vless" && !outbound["mux"]!["enabled"]!.GetValue<bool>());
Check(config["routing"]!["rules"]!.AsArray().Single()!["outboundTag"]!.GetValue<string>() == outbound["tag"]!.GetValue<string>());
Check(config["dns"] is null && config["log"]!["loglevel"]!.GetValue<string>() == "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<string>() == node.Host && endpoint["port"]!.GetValue<int>() == node.Port);
Check(user["id"]!.GetValue<string>() == node.Uuid && user["flow"]!.GetValue<string>() == node.Flow && user["encryption"]!.GetValue<string>() == "none");
var stream = outbound["streamSettings"]!;
Check(stream["network"]!.GetValue<string>() == "raw" && stream["security"]!.GetValue<string>() == "reality");
var reality = stream["realitySettings"]!;
Check(reality["serverName"]!.GetValue<string>() == node.ServerName && reality["fingerprint"]!.GetValue<string>() == node.Fingerprint);
Check(reality["password"]!.GetValue<string>() == node.PublicKey && reality["shortId"]!.GetValue<string>() == node.ShortId);
Check(reality["show"]!.GetValue<bool>() == false && reality["spiderX"]!.GetValue<string>() == "/");
}
});
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<int>() == 17902);
Check(bound["outbounds"]!.AsArray().Single()!["streamSettings"]!["sockopt"]!["interface"]!.GetValue<string>() == "Беспроводная сеть");
Check(XrayConfigBuilder.Build(Fixture())["outbounds"]!.AsArray().Single()!["streamSettings"]!["sockopt"] is null);
foreach (var port in new[] { 0, -1, 65536 }) Throws<ArgumentOutOfRangeException>(() => XrayConfigBuilder.Build(Fixture(), port));
foreach (var name in new[] { "", " ", "adapter\nname", new string('a', 257) }) Throws<ArgumentException>(() => 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<int> 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<T>(Action action) where T : Exception
{
try { action(); } catch (T) { return; }
throw new InvalidOperationException("Xray configuration validation regression.");
}
}