Release QVPN 1.0.5 with Xray and resilient tunnel recovery

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

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