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