Implement QVPN Windows client for BlancVPN with resilient VPN service
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
dist/
|
||||
tools/core*/
|
||||
tools/core.zip
|
||||
tools/sing-box-*-source.zip
|
||||
research/*.apk
|
||||
research/phone*.xml
|
||||
research/phone*.png
|
||||
*.user
|
||||
*.secrets
|
||||
src/QVPN.App/*.png
|
||||
src/QVPN.App/*smoke.json
|
||||
research/*.png
|
||||
research/phone-package.txt
|
||||
research/*verification.json
|
||||
research/service-regression.json
|
||||
research/recovery-socks.json
|
||||
research/disconnect-write-failure.json
|
||||
research/scm-recovery.txt
|
||||
@@ -0,0 +1 @@
|
||||
<Project><PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><LangVersion>latest</LangVersion><Version>1.0.0</Version><EnableWindowsTargeting>true</EnableWindowsTargeting><Deterministic>true</Deterministic></PropertyGroup></Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
# QVPN для Windows
|
||||
|
||||
Собственный клиент для **BlancVPN**. Импортирует HTTPS-ссылку `*.withblancvpn.online/s/…`, получает локации VLESS/Reality и подключает весь Windows через виртуальный сетевой адаптер. Другие VPN-провайдеры и произвольные конфигурации не принимаются. Личный ключ в исходники и установщик не включён.
|
||||
|
||||
## Установка и работа
|
||||
|
||||
1. Запустите `QVPN-Setup-1.0.0.exe`. Windows попросит права администратора один раз для установки VPN-службы.
|
||||
2. Откройте ярлык QVPN. Вставьте ссылку BlancVPN в поле ключа и нажмите «Добавить ключ».
|
||||
3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить».
|
||||
|
||||
Крестик окна сворачивает QVPN в область уведомлений. Выход из интерфейса сохраняет VPN. Для остановки соединения нажмите «Отключить». После перезагрузки служба восстанавливает последнее состояние подключения; открывать окно и снова подтверждать доступ не требуется. Автозапуск самого окна можно включить в настройках.
|
||||
|
||||
«Российские сервисы напрямую» отправляет локальную сеть и российские адреса напрямую. Для российских сайтов используются суффиксы `.ru`, `.su`, `.рф` и поставляемые открытые GeoIP/Geosite-правила. Это приближение к функции мобильного BlancVPN: его закрытые списки приложений и ресурсов не копировались. Отключение настройки отправляет публичный трафик через VPN; локальная сеть остаётся доступной.
|
||||
|
||||
Задержка в списке локаций — время TCP-подключения, а не измерение скорости. При включённой автоматической смене локации QVPN может выбрать запасную, когда выбранная недоступна. Текущая локация всегда отображается в окне.
|
||||
|
||||
## Восстановление соединения
|
||||
|
||||
- Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна.
|
||||
- Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя.
|
||||
- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд.
|
||||
- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации.
|
||||
- Возврат из сна и появление сети восстанавливают VPN. Обновление подписки раз в 6 часов сохраняет рабочее соединение, если его параметры не изменились.
|
||||
- Windows автоматически перезапускает аварийно завершившуюся службу. Job Object закрывает принадлежащие ей VPN-процессы при её завершении.
|
||||
- Журнал ограничен по размеру и содержит события подключения, без ключа, UUID и истории посещений.
|
||||
|
||||
Это не постоянный kill switch: при восстановлении или остановке службы системный маршрут может вернуться к обычной сети. Краткая проверка восстановления не доказывает отсутствие всех сбоев в многосуточной эксплуатации. Истечение подписки, блокировки и недоступность всех серверов не устраняются клиентом; состояние будет показано в окне.
|
||||
|
||||
## Разработка и проверка
|
||||
|
||||
Требуется .NET SDK 10. Подготовка зависимостей в новой копии исходников: `powershell -File scripts\prepare-core.ps1`; сборка: `powershell -File scripts\build.ps1`. Приложение и служба публикуются для Windows x64 с включённым .NET runtime; установка .NET на целевом компьютере не нужна.
|
||||
|
||||
Тесты: `dotnet run --project tests\QVPN.Tests -c Release`.
|
||||
|
||||
Диагностика без изменения системных маршрутов:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Тестовый режим доступен только при ручном консольном запуске; обычная служба всегда создаёт системный TUN. Для остановки тестового режима используйте Ctrl+C. Не запускайте тестовую и установленную службу одновременно.
|
||||
|
||||
Удаление доступно через «Установленные приложения». Настройки сохраняются, чтобы случайное удаление не уничтожило ключ. Подробности устройства и формата: `research/analysis.md`.
|
||||
|
||||
## Открытые компоненты
|
||||
|
||||
Неизменённый 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 и закрытый код мобильного приложения в поставку не включены.
|
||||
@@ -0,0 +1,75 @@
|
||||
# QVPN: обследование BlancVPN и проверяемые решения
|
||||
|
||||
Дата обследования: 4 октября 2026 года, Москва. В этом документе нет ссылки подписки, UUID, ключей Reality и адресов серверов из личной подписки.
|
||||
|
||||
## Что подтверждено на подключённом телефоне
|
||||
|
||||
`dumpsys package` установленного `com.blancvpn.app` показал `versionName=1.8.7`, `versionCode=189`, `minSdk=28`, `targetSdk=36`. Запись сохранена в `phone-package.txt`. Основное окно на `phone.png` показывает установленное VPN-соединение, выбранную локацию, большой переключатель подключения, переход к локациям и режим «Российские сервисы открываются без VPN».
|
||||
|
||||
Из APK исследованы имена компонентов и файлов, без переноса программного кода или ресурсов в QVPN. Обнаружены `XrayVpnService`, `AwgVpnService`, Flutter-ресурсы и библиотеки `libflutter.so`, `libapp.so`, `libgojni.so`, `libhev-socks5-tunnel.so`, `libam-go.so`, `libam-quick.so`, `libam.so`. Это подтверждает наличие Flutter-интерфейса и отдельных нативных VPN-компонентов. Имена файлов сами по себе не устанавливают точную версию ядра Xray, его текущие настройки или причину стабильности телефонного приложения.
|
||||
|
||||
В APK также обнаружены названия встроенных правил `ru_merged_geoip.dat`, `ru_merged_geosite.dat`, `ru_whitelist_rules_v1.json` и `ru_app_ids.json`. Они указывают на отдельную маршрутизацию российских ресурсов. Их содержимое не используется как база правил QVPN; для Windows выбраны открытые наборы и собственные правила. Фирменные изображения, шрифты, код приложения, токены встроенных сервисов и закрытые списки BlancVPN не копируются.
|
||||
|
||||
## Что подтверждено по подписке
|
||||
|
||||
Приватно полученное содержимое содержит 50 локаций VLESS с Reality поверх TCP. Парсер QVPN обработал эти 50 записей; диагностический вывод содержит только `LIVE_PARSE nodes=50`. Ключ не включён в исходники, тестовые фикстуры и отчёт.
|
||||
|
||||
Импорт ограничен HTTPS-ссылкой домена `withblancvpn.online` или его поддомена, маршрутом `/s/…`, стандартным портом, без userinfo, фрагмента и query. Отклоняются чужие домены, подмена суффикса, Unicode/punycode-подмена имени провайдера, HTTP и перенаправления HTTP. Для этой версии принимаются только проверенный формат VLESS/Reality TCP и публичные IP-адреса серверов. Локальные, loopback, private и link-local адреса не могут стать целью привилегированного VPN-процесса через подписку. Это намеренно узкая совместимость с обследованной подпиской; новые форматы провайдера требуют отдельной реализации.
|
||||
|
||||
## Почему Happ разрывает соединение
|
||||
|
||||
Причина пока не установлена. В проведённой проверке Windows за семь дней среди последних 300 записей ошибок приложений не найдено подтверждённого падения Happ. Ограниченное окно журнала не доказывает отсутствие всех падений и не отличает остановку ядра, сетевую ошибку, сон, обновление маршрутов и ручное отключение. Нельзя утверждать, что найдена и устранена конкретная ошибка Happ.
|
||||
|
||||
Для QVPN выбран Windows Service, работающий отдельно от окна. Закрытие интерфейса не должно прекращать VPN. Доступность проверяется запросом через отдельный локальный прокси, принудительно направленный в VPN; прямой доступ российских сервисов не должен дать ложное подтверждение исправности туннеля. Восстановление после ошибки использует ограниченную задержку 2, 4, 8, 16, 32, 60 секунд и смену локации при разрешённом автоматическом переключении. Успешная единичная проверка сбрасывает серию ошибок, но не накопленный backoff. Намерение пользователя «подключено» хранится отдельно от фактического состояния.
|
||||
|
||||
Восстановление процесса, реальная передача данных, рестарт службы, сон/пробуждение и многочасовая стабильность — разные границы проверки. Тест алгоритма восстановления не подтверждает работу сети, а краткий успешный сетевой тест не подтверждает многочасовую стабильность. В итоговой передаче следует указывать отдельно фактически выполненные проверки.
|
||||
|
||||
## Ядро и маршрутизация
|
||||
|
||||
QVPN запускает неизменённый официальный Windows x64 `sing-box 1.14.2` отдельным процессом. На момент обследования это последний стабильный релиз, отмеченный upstream как Latest. Источник: [официальный релиз v1.14.2](https://github.com/SagerNet/sing-box/releases/tag/v1.14.2).
|
||||
|
||||
SHA-256 архива `sing-box-1.14.2-windows-amd64.zip` повторно вычислен локально и совпал с `digest` релизного API:
|
||||
|
||||
`c2d8bfff918755808781dfdeeb8581b6c91eb3a243d9a7b55483cfc0c0684d32`.
|
||||
|
||||
SHA-256 распакованного `sing-box.exe`:
|
||||
|
||||
`7bbef1dea9189ee12799ae834ea4b4658355da25c47a21ad8804904c0ccd9410`.
|
||||
|
||||
Конфигурация QVPN включает TUN с IPv4/IPv6, `auto_route`, `strict_route`, перехват DNS, DoH через VPN, обход локальных IP и управляемый обход российских доменов/открытых rule-set. Официальная документация описывает [TUN и ограничения Windows](https://sing-box.sagernet.org/configuration/inbound/tun/), [VLESS](https://sing-box.sagernet.org/configuration/outbound/vless/), [Reality/TLS](https://sing-box.sagernet.org/configuration/shared/tls/), [DoH](https://sing-box.sagernet.org/configuration/dns/server/https/) и [маршрутизацию](https://sing-box.sagernet.org/configuration/route/). Документация публикуется для нескольких версий; допустимость созданного JSON должна проверяться именно бинарником 1.14.2.
|
||||
|
||||
## Лицензия и исходники ядра
|
||||
|
||||
Upstream `LICENSE` для точного тега разрешает sing-box по GPL версии 3 или более поздней, сохраняет copyright и отдельно ограничивает использование имени/ассоциации производных работ: [LICENSE v1.14.2](https://raw.githubusercontent.com/SagerNet/sing-box/v1.14.2/LICENSE). QVPN не использует имя ядра как своё название и не заявляет принадлежность к SagerNet или BlancVPN.
|
||||
|
||||
В пакет с ядром следует включать upstream `LICENSE`, полный текст GPL-3.0 и уведомление: «sing-box 1.14.2, Copyright (C) 2022 nekohasekai. Ядро распространяется без изменений. Лицензия GPL-3.0-or-later».
|
||||
|
||||
Точный исходный тег: [SagerNet/sing-box v1.14.2](https://github.com/SagerNet/sing-box/tree/v1.14.2); архив исходников: [v1.14.2.zip](https://github.com/SagerNet/sing-box/archive/refs/tags/v1.14.2.zip). При распространении бинарников необходимо сохранить лицензионные уведомления и обеспечить соответствующий исходный код и материалы сборки по условиям GPL. Одна ссылка на репозиторий не заменяет проверку полноты исходного комплекта для выбранного способа распространения.
|
||||
|
||||
## Автоматические проверки QVPN.Core
|
||||
|
||||
`dotnet run --project tests/QVPN.Tests/QVPN.Tests.csproj -c Release` — 16 групп проверок, 16 успешно, 0 ошибок. Внешний тестовый фреймворк не требуется.
|
||||
|
||||
Проверены допустимая подписка и запреты импорта, 50 синтетических записей в открытом и base64-виде, удаление дублей, очистка имён, ошибочные Reality-параметры, запрет локальных адресов, DPAPI roundtrip/замена/backup/повреждение и наследуемые ACL защищённого каталога, отсутствие открытого текста в файлах, двусторонний протокол IPC и ограничения размера, маршрутизация health-проверки, TUN/DNS, переключатель российских ресурсов и политика восстановления. DPAPI `LocalMachine` требует защищённого каталога службы: шифрование само по себе не ограничивает другие локальные учётные записи.
|
||||
|
||||
Для приватной проверки содержимого: `QVPN.Tests --live-parse <private-file>`. Для интеграционной проверки службы предусмотрено `QVPN.Tests --client <command> --value-file <private-file>`; запрос и значение ключа не выводятся, ответ содержит только публичный Status. Отчёт не является доказательством многочасовой работы VPN.
|
||||
|
||||
`QVPN.Tests --engine-check <sing-box.exe>` успешно проверил JSON официальным бинарником 1.14.2 для четырёх сочетаний TUN on/off и российского обхода on/off, все `exit=0`. Проверка использует только синтетический ключ и не подключается к серверу. Она подтверждает допустимость конфигурации для выбранного ядра, отдельно от сетевой работы.
|
||||
|
||||
После единичной ошибки загрузки в .NET проведено повторное обследование командой `QVPN.Tests --download --value-file <private-file> --diagnose`. Непосредственный `Subscription.DownloadAsync` получил 50 локаций. Диагностические запросы direct, system-proxy и IPv4-direct вернули HTTP 200 с корректной TLS-цепочкой (`SslPolicyErrors.None`). DNS на момент проверки вернул один IPv4 и не вернул IPv6. Ошибка не воспроизведена; изменение проверки сертификатов или принудительное отключение IPv6 этим замером не обосновано. Диагностика выводит только типы исключений, HRESULT и значения enum, без `Exception.Message`, URL, адресов и credentials. Устойчивость загрузки должна обеспечиваться повтором временной сетевой ошибки и сохранением последней рабочей подписки.
|
||||
|
||||
## Проверка обработчика службы после исправлений
|
||||
|
||||
В review найдены и исправлены: потеря IPC-слушателя при `JsonException`, ожидание бесконечного соседнего цикла вместо реакции SCM на fatal, сохранение работающего ядра после ошибки записи при отключении и неограниченное ожидание TCP-измерений при остановке. Дополнительно исправлены права `CreateNewInstance` диагностического сервера: полный доступ получает текущая серверная учётная запись, тогда как пользовательский клиент в режиме службы SYSTEM сохраняет только клиентские права.
|
||||
|
||||
Реальная команда `QVPN.Tests --malformed-service --evidence-file research/service-regression.json` отправила восемь повреждённых JSON-фреймов с клиентским `TokenImpersonationLevel.Identification` в уже запущенный обработчик. Каждый запрос был закрыт, после каждого `status` успешно ответил; до и после оставались 50 локаций и состояние `disconnected`. Во время проверки ядро не было запущено, поэтому этот замер подтверждает живучесть IPC и сохранение состояния, а не непрерывность активного туннеля. Команда не запускала TUN, не меняла настройки и не перезапускала службу. Публичный JSON содержит только время, счётчики, состояния и PID.
|
||||
|
||||
## Проверка установленного приложения, 4 октября 2026
|
||||
|
||||
Установленная служба LocalSystem и системный адаптер QVPN работают. HTTPS без явного прокси через системную маршрутизацию вернул 204. На рабочем ключе доступны 50 локаций.
|
||||
|
||||
Принудительное завершение установленного TUN-ядра: восстановление за 11.1 секунды без ввода ключа. Принудительное завершение службы: SCM восстановил службу и подключение за 11.4 секунды, дочернее ядро предыдущей службы не осталось работать. Последующий HTTPS-запрос через системный маршрут вернул 204. Свидетельство: installed-recovery-verification.json.
|
||||
|
||||
Старый installed-verification.json содержит ошибочный отказ теста: после завершения ядра проверка сразу прочитала предыдущий снимок connected со старым PID. Исправлено ожидание нового PID одновременно с проверенным состоянием connected. Это ошибка первоначального проверочного скрипта, а не подтвержденный отказ восстановления приложения.
|
||||
|
||||
В конце проверки VPN оставлен включённым, локация Цюрих, Швейцария. Отрисовка установленного интерфейса по реальному статусу — installed-live.png, не демонстрационный режим. Многосуточная эксплуатация и реальный цикл сна/пробуждения этой короткой проверкой не подтверждаются.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"tag": "v1.14.2",
|
||||
"assets": [
|
||||
{
|
||||
"url": "https://api.github.com/repos/SagerNet/sing-box/releases/assets/585841082",
|
||||
"id": 585841082,
|
||||
"node_id": "RA_kwDOHlge-M4i6zm6",
|
||||
"name": "sing-box-1.14.2-windows-amd64.zip",
|
||||
"label": "",
|
||||
"uploader": {
|
||||
"login": "github-actions[bot]",
|
||||
"id": 41898282,
|
||||
"node_id": "MDM6Qm90NDE4OTgyODI=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/github-actions%5Bbot%5D",
|
||||
"html_url": "https://github.com/apps/github-actions",
|
||||
"followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers",
|
||||
"following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs",
|
||||
"repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos",
|
||||
"events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events",
|
||||
"type": "Bot",
|
||||
"user_view_type": "public",
|
||||
"site_admin": false
|
||||
},
|
||||
"content_type": "application/zip",
|
||||
"state": "uploaded",
|
||||
"size": 32857903,
|
||||
"digest": "sha256:c2d8bfff918755808781dfdeeb8581b6c91eb3a243d9a7b55483cfc0c0684d32",
|
||||
"download_count": 128475,
|
||||
"created_at": "2026-09-24T11:43:37Z",
|
||||
"updated_at": "2026-09-24T11:43:38Z",
|
||||
"browser_download_url": "https://github.com/SagerNet/sing-box/releases/download/v1.14.2/sing-box-1.14.2-windows-amd64.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "geoip-ru.srs",
|
||||
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/7fe82a879ad2666526730c195b55a6d8d9147908/geoip-ru.srs",
|
||||
"sha256": "6e23f5580dd443e2f9c4895adafee8d199c1a3487182ad5f0a2256cf59c7e53a"
|
||||
},
|
||||
{
|
||||
"name": "geosite-category-ru.srs",
|
||||
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/53e0e99be15953f1865c4f3f4cffe8c8f8543dff/geosite-category-ru.srs",
|
||||
"sha256": "91cac7d9735f43d54d26102015f17c8a90cb740086e1bde290e2c03a4eb003a9"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$Configuration = 'Release', [switch]$SkipTests)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$distributionRoot = Join-Path $projectRoot 'dist'
|
||||
$payloadRoot = Join-Path $distributionRoot 'payload'
|
||||
$setupPublishRoot = Join-Path $distributionRoot 'setup-publish'
|
||||
|
||||
function Reset-BuildDirectory([string]$Path) {
|
||||
$absoluteTarget = [IO.Path]::GetFullPath($Path)
|
||||
$allowedRoot = [IO.Path]::GetFullPath($distributionRoot) + [IO.Path]::DirectorySeparatorChar
|
||||
if (-not $absoluteTarget.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove outside dist: $absoluteTarget"
|
||||
}
|
||||
if (Test-Path -LiteralPath $absoluteTarget) {
|
||||
if ((Get-Item -LiteralPath $absoluteTarget -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "Build directory is a reparse point: $absoluteTarget"
|
||||
}
|
||||
$linkedEntry = Get-ChildItem -LiteralPath $absoluteTarget -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } | Select-Object -First 1
|
||||
if ($linkedEntry) { throw "Build directory contains a reparse point: $($linkedEntry.FullName)" }
|
||||
Remove-Item -LiteralPath $absoluteTarget -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $absoluteTarget -Force | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-DotNet([string[]]$Arguments) {
|
||||
& dotnet @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet failed ($LASTEXITCODE): $($Arguments -join ' ')" }
|
||||
}
|
||||
|
||||
$coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64'
|
||||
$rulesRoot = Join-Path $projectRoot 'tools/rules'
|
||||
foreach ($coreFile in @('sing-box.exe', 'libcronet.dll', 'LICENSE')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $coreRoot $coreFile) -PathType Leaf)) { throw "Missing packaged VPN core: $coreFile" }
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $rulesRoot) -or -not (Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File)) {
|
||||
throw 'Missing routing rules in tools/rules. The installer never downloads dependencies.'
|
||||
}
|
||||
foreach ($project in @('src/QVPN.App/QVPN.App.csproj', 'src/QVPN.Service/QVPN.Service.csproj', 'src/QVPN.Setup/QVPN.Setup.csproj')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $projectRoot $project))) { throw "Missing project: $project" }
|
||||
}
|
||||
|
||||
if (-not $SkipTests) {
|
||||
$testProjects = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'tests') -Filter '*.csproj' -File -Recurse -ErrorAction SilentlyContinue)
|
||||
foreach ($testProject in $testProjects) { Invoke-DotNet -Arguments @('run', '--project', $testProject.FullName, '-c', $Configuration) }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $distributionRoot -Force | Out-Null
|
||||
Reset-BuildDirectory $payloadRoot
|
||||
Reset-BuildDirectory $setupPublishRoot
|
||||
Write-Host 'Publishing Windows UI and service with the bundled .NET runtime...'
|
||||
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.App/QVPN.App.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true', '-p:IncludeNativeLibrariesForSelfExtract=true', '-o', (Join-Path $payloadRoot 'app'))
|
||||
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')
|
||||
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 includes sing-box v1.14.2 (Windows amd64), copyright nekohasekai / SagerNet.
|
||||
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
|
||||
Release and original archive: https://github.com/SagerNet/sing-box/releases/tag/v1.14.2
|
||||
The bundled rule sets are from https://github.com/SagerNet/sing-geosite and https://github.com/SagerNet/sing-geoip.
|
||||
See the respective repositories for source data and notices.
|
||||
'@ | Set-Content -LiteralPath (Join-Path $payloadRoot 'THIRD-PARTY-NOTICES.txt') -Encoding utf8
|
||||
|
||||
Get-ChildItem -LiteralPath $payloadRoot -Filter '*.pdb' -Recurse -File | Remove-Item -Force
|
||||
$manifest = [ordered]@{}
|
||||
foreach ($file in (Get-ChildItem -LiteralPath $payloadRoot -Recurse -File | Sort-Object FullName)) {
|
||||
$relativePath = [IO.Path]::GetRelativePath($payloadRoot, $file.FullName)
|
||||
$manifest[$relativePath] = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $payloadRoot 'payload-manifest.json') -Encoding utf8
|
||||
$payloadZip = Join-Path $distributionRoot 'payload.zip'
|
||||
if (Test-Path -LiteralPath $payloadZip) { Remove-Item -LiteralPath $payloadZip -Force }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($payloadRoot, $payloadZip, [IO.Compression.CompressionLevel]::Optimal, $false)
|
||||
Write-Host 'Embedding the complete payload in the offline Windows installer...'
|
||||
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.Setup/QVPN.Setup.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-o', $setupPublishRoot)
|
||||
$installer = Join-Path $distributionRoot 'QVPN-Setup-1.0.0.exe'
|
||||
Copy-Item -LiteralPath (Join-Path $setupPublishRoot 'QVPN-Setup.exe') -Destination $installer -Force
|
||||
$installerHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash
|
||||
"$installerHash QVPN-Setup-1.0.0.exe" | Set-Content -LiteralPath (Join-Path $distributionRoot 'QVPN-Setup-1.0.0.sha256') -Encoding ascii
|
||||
Write-Host "Installer: $installer"
|
||||
Write-Host "SHA256: $installerHash"
|
||||
@@ -0,0 +1,94 @@
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory=$true)][string]$OwnerSid)
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$report=Join-Path $repo 'research/installed-verification.json'
|
||||
$dll=Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
|
||||
$root=Join-Path $env:ProgramFiles 'QVPN'
|
||||
$happWasRunning=$false
|
||||
$checks=[Collections.Generic.List[object]]::new()
|
||||
function Send([string]$command,[string]$valueFile='') {
|
||||
$arguments=@($dll,'--client',$command)
|
||||
if($valueFile){$arguments+=@('--value-file',$valueFile)}
|
||||
$raw=& dotnet @arguments
|
||||
if($raw -notmatch '^\{'){throw "QVPN command unavailable: $command"}
|
||||
$response=$raw|ConvertFrom-Json
|
||||
if(!$response.Ok){throw "QVPN command failed: $command"}
|
||||
return $response.Status
|
||||
}
|
||||
function Wait-Connected([int]$seconds=60,[int]$DifferentEngine=0) {
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
try {$status=Send 'status';if($status.State -eq 'connected' -and (!$DifferentEngine -or $status.EnginePid -ne $DifferentEngine)){return $status}}catch{}
|
||||
}while($timer.Elapsed.TotalSeconds -lt $seconds)
|
||||
throw 'QVPN did not establish a checked connection within the verification window'
|
||||
}
|
||||
function Write-Report([bool]$success,[string]$failure='') {
|
||||
[pscustomobject]@{Completed=$success;At=[DateTimeOffset]::Now.ToString('o');Failure=$failure;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8
|
||||
}
|
||||
try {
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if(![Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator token is required'}
|
||||
$setup=Join-Path $repo 'dist/QVPN-Setup-1.0.0.exe'
|
||||
$expected=(Get-Content (Join-Path $repo 'dist/QVPN-Setup-1.0.0.sha256') -Raw).Split(' ')[0]
|
||||
if((Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash -ne $expected){throw 'Installer hash mismatch'}
|
||||
$install=Start-Process -FilePath $setup -ArgumentList @('--silent','--owner',$OwnerSid) -PassThru
|
||||
if(!$install.WaitForExit(60000)){throw 'Installer still running'}
|
||||
if($install.ExitCode -ne 0){throw "Installer failed with exit code $($install.ExitCode)"}
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
if(!$service -or $service.State -ne 'Running' -or $service.StartName -ne 'LocalSystem'){throw 'Installed service identity/state check failed'}
|
||||
$owner=Get-ItemPropertyValue 'HKLM:\SOFTWARE\QVPN' 'OwnerSid'
|
||||
if($owner -ne $OwnerSid){throw 'Installed owner mismatch'}
|
||||
$dataAcl=Get-Acl -LiteralPath (Join-Path $env:ProgramData 'QVPN')
|
||||
$unexpected=@($dataAcl.Access|Where-Object {$_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18','S-1-5-32-544')})
|
||||
if($unexpected.Count -gt 0){throw 'Data ACL includes an unexpected reader'}
|
||||
$checks.Add([pscustomobject]@{Scenario='Offline install, protected data and LocalSystem service';Passed=$true})
|
||||
$import=Send 'import' 'C:\Tools\QVPN-private\subscription-url.txt'
|
||||
$checks.Add([pscustomobject]@{Scenario='Live Blanc subscription import';Passed=($import.Nodes.Count -eq 50);Nodes=$import.Nodes.Count})
|
||||
# Replace the running HAPP session only after QVPN has a validated subscription.
|
||||
$happ=Get-Service -Name HappService -ErrorAction SilentlyContinue
|
||||
$happWasRunning=$happ -and $happ.Status -eq 'Running'
|
||||
Get-Process Happ -ErrorAction SilentlyContinue | ForEach-Object {if($_.Path -eq 'C:\Program Files\FlyFrogLLC\Happ\Happ.exe'){Stop-Process -Id $_.Id -Force}}
|
||||
if($happWasRunning){Stop-Service -Name HappService; (Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30))}
|
||||
Start-Sleep -Seconds 2
|
||||
$null=Send 'connect'
|
||||
$status=Wait-Connected
|
||||
$adapter=Get-NetAdapter -Name QVPN -ErrorAction Stop
|
||||
if($adapter.Status -ne 'Up'){throw 'QVPN TUN adapter is not up'}
|
||||
$routes=@(Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 | Where-Object {$_.DestinationPrefix -in @('0.0.0.0/1','128.0.0.0/1','0.0.0.0/0')})
|
||||
if($routes.Count -eq 0){throw 'No public IPv4 routes through QVPN TUN'}
|
||||
$http=& curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' https://cp.cloudflare.com/generate_204
|
||||
if($LASTEXITCODE -ne 0 -or $http -ne '204'){throw 'System route HTTPS request failed'}
|
||||
$checks.Add([pscustomobject]@{Scenario='Windows TUN and HTTPS through system routes';Passed=$true;Adapter=$adapter.Name;Routes=$routes.Count;HttpCode=$http;State=$status.State})
|
||||
$previousEngine=$status.EnginePid
|
||||
$engine=Get-Process -Id $previousEngine
|
||||
if($engine.Path -ne (Join-Path $root 'core/sing-box.exe')){throw 'Unexpected VPN process executable'}
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
Stop-Process -Id $previousEngine -Force
|
||||
$status=Wait-Connected -DifferentEngine $previousEngine
|
||||
if($status.EnginePid -eq $previousEngine){throw 'VPN engine was not replaced'}
|
||||
$checks.Add([pscustomobject]@{Scenario='TUN engine crash recovery without re-entering key';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);Restarts=$status.Restarts})
|
||||
$oldEngine=$status.EnginePid
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
$process=Get-Process -Id $service.ProcessId
|
||||
if($process.Path -ne (Join-Path $root 'service/QVPN.Service.exe')){throw 'Unexpected service executable'}
|
||||
$oldService=$process.Id
|
||||
$timer.Restart()
|
||||
Stop-Process -Id $oldService -Force
|
||||
Start-Sleep -Seconds 2
|
||||
$orphan=Get-Process -Id $oldEngine -ErrorAction SilentlyContinue
|
||||
if($orphan -and $orphan.Path -eq (Join-Path $root 'core/sing-box.exe')){throw 'VPN process survived daemon job disposal'}
|
||||
$status=Wait-Connected
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
if($service.ProcessId -eq $oldService){throw 'SCM did not replace crashed service'}
|
||||
$checks.Add([pscustomobject]@{Scenario='SCM service crash recovery, no orphan engine, persisted key and connection intent';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);State=$status.State;Nodes=$status.Nodes.Count})
|
||||
& sc.exe qfailure QVPNService | Out-File -LiteralPath (Join-Path $repo 'research/scm-recovery.txt') -Encoding utf8
|
||||
Write-Report $true
|
||||
}catch{
|
||||
$failure=$_.Exception.GetType().Name+': '+$_.Exception.Message
|
||||
try{$null=Send 'disconnect'}catch{Stop-Service QVPNService -ErrorAction SilentlyContinue}
|
||||
if($happWasRunning){Start-Service HappService -ErrorAction SilentlyContinue}
|
||||
Write-Report $false $failure
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$tools=Join-Path $root 'tools'
|
||||
New-Item -ItemType Directory -Path $tools -Force | Out-Null
|
||||
$archive=Join-Path $tools 'core.zip'
|
||||
Invoke-WebRequest 'https://github.com/SagerNet/sing-box/releases/download/v1.14.2/sing-box-1.14.2-windows-amd64.zip' -OutFile $archive
|
||||
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne 'C2D8BFFF918755808781DFDEEB8581B6C91EB3A243D9A7B55483CFC0C0684D32') {throw 'VPN core archive hash mismatch'}
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $tools 'core-unpacked') -Force
|
||||
$rules=Join-Path $tools 'rules'
|
||||
New-Item -ItemType Directory -Path $rules -Force | Out-Null
|
||||
$sources=Get-Content (Join-Path $root 'research/rules-sources.json') -Raw | ConvertFrom-Json
|
||||
foreach($source in $sources) {
|
||||
$target=Join-Path $rules $source.name
|
||||
Invoke-WebRequest $source.url -OutFile $target
|
||||
if((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ne $source.sha256) {throw "Routing rules hash mismatch: $($source.name)"}
|
||||
}
|
||||
Invoke-WebRequest 'https://github.com/SagerNet/sing-box/archive/refs/tags/v1.14.2.zip' -OutFile (Join-Path $tools 'sing-box-1.14.2-source.zip')
|
||||
Write-Output 'Pinned core, routing rules and upstream source downloaded and checked.'
|
||||
@@ -0,0 +1,16 @@
|
||||
[CmdletBinding()]
|
||||
param([switch]$RemoveData, [switch]$Silent)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installRoot = [IO.Path]::GetFullPath((Join-Path $env:ProgramFiles 'QVPN'))
|
||||
$setup = Join-Path $installRoot 'QVPN-Setup.exe'
|
||||
if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { throw 'QVPN installer was not found in Program Files.' }
|
||||
if ((Get-Item -LiteralPath $installRoot -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The QVPN directory is a reparse point.' }
|
||||
$arguments = @('--uninstall')
|
||||
if ($RemoveData) { $arguments += '--remove-data' }
|
||||
if ($Silent) { $arguments += '--silent' }
|
||||
$process = Start-Process -FilePath $setup -ArgumentList $arguments -Verb RunAs -WindowStyle Hidden -PassThru -Wait
|
||||
if ($process.ExitCode -ne 0) { throw "QVPN uninstaller exited with code $($process.ExitCode). Check the setup log in your TEMP folder." }
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(90)
|
||||
while ((Test-Path -LiteralPath $installRoot) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 500 }
|
||||
if (Test-Path -LiteralPath $installRoot) { throw 'Removal did not finish. Check QVPN-Setup-*.log in your TEMP folder.' }
|
||||
Write-Host 'QVPN removed.'
|
||||
@@ -0,0 +1,57 @@
|
||||
[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'
|
||||
$root=Join-Path $env:ProgramFiles 'QVPN'
|
||||
$report=Join-Path $repo 'research/installed-recovery-verification.json'
|
||||
$checks=[Collections.Generic.List[object]]::new()
|
||||
function Status {
|
||||
$raw=& dotnet $dll --client status
|
||||
if($raw -notmatch '^\{'){throw 'Status endpoint not available'}
|
||||
$response=$raw|ConvertFrom-Json
|
||||
if(!$response.Ok){throw 'Status endpoint returned failure'}
|
||||
return $response.Status
|
||||
}
|
||||
function Wait-Recovered([int]$oldEngine,[int]$oldService=0) {
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
try {
|
||||
$s=Status
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
if($s.State -eq 'connected' -and $s.DesiredConnected -and $s.EnginePid -and $s.EnginePid -ne $oldEngine -and (!$oldService -or $service.ProcessId -ne $oldService)){return $s}
|
||||
}catch{}
|
||||
}while($timer.Elapsed.TotalSeconds -lt 60)
|
||||
throw 'Automatic recovery did not complete within 60 seconds'
|
||||
}
|
||||
try {
|
||||
$s=Status
|
||||
if($s.State -ne 'connected' -or !$s.DesiredConnected){throw 'QVPN must already be connected for this test'}
|
||||
$oldEngine=$s.EnginePid
|
||||
$p=Get-Process -Id $oldEngine
|
||||
if($p.Path -ne (Join-Path $root 'core/sing-box.exe')){throw 'Unexpected engine executable'}
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
Stop-Process -Id $oldEngine -Force
|
||||
$s=Wait-Recovered $oldEngine
|
||||
$checks.Add([pscustomobject]@{Scenario='Installed TUN engine crash recovery';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);Restarts=$s.Restarts;KeyEnteredAgain=$false})
|
||||
$oldEngine=$s.EnginePid
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
$p=Get-Process -Id $service.ProcessId
|
||||
if($p.Path -ne (Join-Path $root 'service/QVPN.Service.exe')){throw 'Unexpected service executable'}
|
||||
$oldService=$p.Id
|
||||
$timer.Restart()
|
||||
Stop-Process -Id $oldService -Force
|
||||
Start-Sleep -Seconds 2
|
||||
$orphan=Get-Process -Id $oldEngine -ErrorAction SilentlyContinue
|
||||
if($orphan -and $orphan.Path -eq (Join-Path $root 'core/sing-box.exe')){throw 'Orphan engine survived service crash'}
|
||||
$s=Wait-Recovered $oldEngine $oldService
|
||||
$checks.Add([pscustomobject]@{Scenario='Installed SCM crash recovery and persisted intent/key';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);OrphanEngine=$false;Nodes=$s.Nodes.Count})
|
||||
$http=& curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' https://cp.cloudflare.com/generate_204
|
||||
if($LASTEXITCODE -ne 0 -or $http -ne '204'){throw 'System HTTPS check failed after recovery'}
|
||||
$checks.Add([pscustomobject]@{Scenario='System HTTPS after both crashes';Passed=$true;HttpCode=$http})
|
||||
[pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8
|
||||
}catch{
|
||||
[pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.GetType().Name+': '+$_.Exception.Message;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Application x:Class="QVPN.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#0A1021" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#121B31" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#93A4C4" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#64DFFF" />
|
||||
<Style TargetType="Window"><Setter Property="FontFamily" Value="Segoe UI"/><Setter Property="FontSize" Value="14"/><Setter Property="Foreground" Value="#EFF5FF"/><Setter Property="Background" Value="{StaticResource BackgroundBrush}"/></Style>
|
||||
<Style TargetType="TextBlock"><Setter Property="TextWrapping" Value="Wrap"/></Style>
|
||||
<Style x:Key="MutedText" TargetType="TextBlock"><Setter Property="Foreground" Value="{StaticResource MutedBrush}"/></Style>
|
||||
<Style x:Key="Card" TargetType="Border"><Setter Property="Background" Value="{StaticResource CardBrush}"/><Setter Property="BorderBrush" Value="#243049"/><Setter Property="BorderThickness" Value="1"/><Setter Property="CornerRadius" Value="22"/></Style>
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="#E9F1FF"/><Setter Property="Background" Value="#1C2A43"/><Setter Property="BorderBrush" Value="#334462"/><Setter Property="BorderThickness" Value="1"/><Setter Property="Padding" Value="17,11"/><Setter Property="Cursor" Value="Hand"/><Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Border x:Name="ButtonBorder" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="11" Padding="{TemplateBinding Padding}"><ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/></Border><ControlTemplate.Triggers><Trigger Property="IsMouseOver" Value="True"><Setter TargetName="ButtonBorder" Property="Opacity" Value="0.84"/></Trigger><Trigger Property="IsPressed" Value="True"><Setter TargetName="ButtonBorder" Property="Opacity" Value="0.66"/></Trigger><Trigger Property="IsEnabled" Value="False"><Setter TargetName="ButtonBorder" Property="Opacity" Value="0.4"/></Trigger><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="ButtonBorder" Property="BorderBrush" Value="#64DFFF"/></Trigger></ControlTemplate.Triggers></ControlTemplate></Setter.Value></Setter>
|
||||
</Style>
|
||||
<Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"><Setter Property="Foreground" Value="#071A2E"/><Setter Property="Background" Value="{StaticResource AccentBrush}"/><Setter Property="BorderThickness" Value="0"/></Style>
|
||||
<Style x:Key="SmallButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"><Setter Property="FontSize" Value="12"/><Setter Property="Padding" Value="12,8"/></Style>
|
||||
<Style TargetType="TextBox"><Setter Property="Foreground" Value="#E9F1FF"/><Setter Property="Background" Value="#0D1528"/><Setter Property="BorderBrush" Value="#334462"/><Setter Property="BorderThickness" Value="1"/><Setter Property="Padding" Value="12,10"/><Setter Property="CaretBrush" Value="#64DFFF"/><Setter Property="SelectionBrush" Value="#30637A"/></Style>
|
||||
<Style TargetType="PasswordBox"><Setter Property="Foreground" Value="#E9F1FF"/><Setter Property="Background" Value="#0D1528"/><Setter Property="BorderBrush" Value="#334462"/><Setter Property="Padding" Value="12,12"/><Setter Property="CaretBrush" Value="#64DFFF"/></Style>
|
||||
<Style TargetType="CheckBox"><Setter Property="Foreground" Value="#E9F1FF"/><Setter Property="Margin" Value="0,9"/><Setter Property="VerticalContentAlignment" Value="Center"/></Style>
|
||||
<Style TargetType="ScrollBar"><Setter Property="Background" Value="Transparent"/><Setter Property="Width" Value="9"/><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="ScrollBar"><Grid Background="Transparent"><Track x:Name="PART_Track" IsDirectionReversed="True" Orientation="Vertical"><Track.DecreaseRepeatButton><RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="False"/></Track.DecreaseRepeatButton><Track.Thumb><Thumb><Thumb.Template><ControlTemplate TargetType="Thumb"><Border Background="#3A4C68" CornerRadius="4" Margin="2,0"/></ControlTemplate></Thumb.Template></Thumb></Track.Thumb><Track.IncreaseRepeatButton><RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="False"/></Track.IncreaseRepeatButton></Track></Grid></ControlTemplate></Setter.Value></Setter></Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using QVPN.Core;
|
||||
|
||||
namespace QVPN.App;
|
||||
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
private Mutex? _instance;
|
||||
private EventWaitHandle? _activation;
|
||||
private RegisteredWaitHandle? _activationWait;
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
// CLI commands have no windows and must stay alive until the pipe response is saved.
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
{
|
||||
UiLog.Write(args.Exception);
|
||||
args.Handled = true;
|
||||
if (MainWindow is MainWindow window) window.ShowError("Ошибка интерфейса. Подключение обслуживается службой и продолжает работать. Повторите действие.");
|
||||
};
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) => { if (args.ExceptionObject is Exception error) UiLog.Write(error); };
|
||||
TaskScheduler.UnobservedTaskException += (_, args) => { UiLog.Write(args.Exception); args.SetObserved(); };
|
||||
if (e.Args.Contains("--command"))
|
||||
{
|
||||
await RunCommandAsync(e.Args);
|
||||
return;
|
||||
}
|
||||
bool preview = e.Args.Contains("--preview");
|
||||
string? screenshot = Argument(e.Args, "--screenshot") ?? Argument(e.Args, "--capture-current");
|
||||
if (!preview && screenshot is null)
|
||||
{
|
||||
var identity = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName;
|
||||
var name = "Local\\QVPN.UI." + identity;
|
||||
_instance = new Mutex(true, name, out bool created);
|
||||
if (!created)
|
||||
{
|
||||
if (EventWaitHandle.TryOpenExisting(name + ".Activate", out var signal)) { using (signal) signal.Set(); }
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
_activation = new EventWaitHandle(false, EventResetMode.AutoReset, name + ".Activate");
|
||||
_activationWait = ThreadPool.RegisterWaitForSingleObject(_activation, (_, _) => Dispatcher.BeginInvoke(() => (MainWindow as MainWindow)?.RestoreWindow()), null, Timeout.Infinite, false);
|
||||
}
|
||||
var main = new MainWindow(preview, screenshot);
|
||||
MainWindow = main;
|
||||
if ((!e.Args.Contains("--background") && !e.Args.Contains("--tray")) || preview || screenshot is not null) main.Show();
|
||||
else main.StartInTray();
|
||||
string? importFile = Argument(e.Args, "--import-file");
|
||||
if (importFile is not null && !preview && screenshot is null)
|
||||
{
|
||||
try { await main.ImportAsync((await File.ReadAllTextAsync(importFile)).Trim()); }
|
||||
catch (Exception error) { UiLog.Write(error); main.RestoreWindow(); main.ShowError("Не удалось прочитать файл подписки. Импортируйте ссылку через кнопку «Подписка»."); }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(string[] args)
|
||||
{
|
||||
Response response;
|
||||
try
|
||||
{
|
||||
string command = Argument(args, "--command") ?? "status";
|
||||
string? file = Argument(args, "--value-file");
|
||||
string? value = file is null ? Argument(args, "--value") : (await File.ReadAllTextAsync(file)).Trim();
|
||||
string? enabledArg = Argument(args, "--enabled");
|
||||
bool? enabled = enabledArg is null ? null : bool.Parse(enabledArg);
|
||||
response = await PipeClient.SendAsync(new Request(command, value, enabled));
|
||||
}
|
||||
catch (Exception error) { UiLog.Write(error); response = new Response(false, SafeError.Describe(error), null); }
|
||||
string? output = Argument(args, "--output");
|
||||
if (output is not null)
|
||||
{
|
||||
try { await File.WriteAllTextAsync(output, JsonSerializer.Serialize(response, Constants.Json)); }
|
||||
catch (Exception error) { UiLog.Write(error); Shutdown(2); return; }
|
||||
}
|
||||
Shutdown(response.Ok ? 0 : 1);
|
||||
}
|
||||
|
||||
private static string? Argument(string[] args, string name)
|
||||
{
|
||||
int index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
protected override void OnSessionEnding(SessionEndingCancelEventArgs e) { (MainWindow as MainWindow)?.PrepareExit(); base.OnSessionEnding(e); }
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
(MainWindow as MainWindow)?.PrepareExit();
|
||||
_activationWait?.Unregister(null);
|
||||
_activation?.Dispose();
|
||||
_instance?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class UiLog
|
||||
{
|
||||
public static readonly string DirectoryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "QVPN-ui");
|
||||
public static void Write(Exception exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
var path = Path.Combine(DirectoryPath, "ui.log");
|
||||
if (File.Exists(path) && new FileInfo(path).Length > 1024 * 1024) File.Move(path, path + ".previous", true);
|
||||
// Exception messages can contain a subscription credential. Store only type and call site.
|
||||
File.AppendAllText(path, $"{DateTimeOffset.Now:O} {exception.GetType().Name} {exception.TargetSite?.DeclaringType?.FullName}.{exception.TargetSite?.Name}{Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<Window x:Class="QVPN.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="QVPN — Blanc VPN для Windows" Style="{StaticResource {x:Type Window}}" Width="1180" Height="800" MinWidth="1040" MinHeight="740" WindowStartupLocation="CenterScreen" Closing="Window_Closing" StateChanged="Window_StateChanged">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="82"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<Border Background="#0E172C" BorderBrush="#1C2942" BorderThickness="0,0,1,0">
|
||||
<Grid Margin="12,24"><Grid.RowDefinitions><RowDefinition Height="64"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
|
||||
<Border Width="46" Height="46" CornerRadius="15" Background="#64DFFF" VerticalAlignment="Top"><TextBlock Text="Q" FontSize="31" FontWeight="Bold" Foreground="#0A1A31" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,-2,0,0"/></Border>
|
||||
<StackPanel Grid.Row="1" Margin="0,36,0,0">
|
||||
<Border Background="#20324F" CornerRadius="13" Height="48"><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="22" Foreground="#64DFFF" HorizontalAlignment="Center" VerticalAlignment="Center" ToolTip="VPN"/></Border>
|
||||
<Button Content="" FontFamily="Segoe MDL2 Assets" FontSize="21" Margin="0,14,0,0" Background="Transparent" BorderThickness="0" Padding="0" Height="48" Click="Settings_Click" ToolTip="Настройки" AutomationProperties.Name="Настройки"/>
|
||||
<Button Content="" FontFamily="Segoe MDL2 Assets" FontSize="21" Margin="0,10,0,0" Background="Transparent" BorderThickness="0" Padding="0" Height="48" Click="Diagnostics_Click" ToolTip="Диагностика" AutomationProperties.Name="Диагностика"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Row="2" Text="1.0" FontSize="11" Foreground="#607694" HorizontalAlignment="Center" Margin="0,0,0,5"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Column="1" Margin="32,25,32,22">
|
||||
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
|
||||
<Grid Margin="0,0,0,23"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||
<StackPanel><StackPanel Orientation="Horizontal"><TextBlock Text="QVPN" FontSize="29" FontWeight="SemiBold"/><Border x:Name="PreviewBadge" Background="#553927" CornerRadius="6" Margin="12,9,0,6" Padding="9,3" Visibility="Collapsed"><TextBlock Text="ДЕМО · VPN отключён" Foreground="#FFCF90" FontSize="10" FontWeight="Bold"/></Border></StackPanel><TextBlock Text="Ваш Blanc VPN. Всегда на связи." Foreground="#93A4C4" FontSize="13" Margin="0,4,0,0"/></StackPanel>
|
||||
<Button Grid.Column="1" Content="Подписка" VerticalAlignment="Center" Click="Subscription_Click" ToolTip="Добавить или заменить ссылку Blanc VPN"/>
|
||||
</Grid>
|
||||
<Border x:Name="ErrorBanner" Grid.Row="1" Background="#44273A" BorderBrush="#78435B" BorderThickness="1" CornerRadius="12" Padding="14,10" Margin="0,0,0,16" Visibility="Collapsed"><Grid><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="32"/></Grid.ColumnDefinitions><TextBlock x:Name="ErrorText" Foreground="#FFD5DE" FontSize="13" VerticalAlignment="Center"/><Button Grid.Column="1" Content="×" Background="Transparent" BorderThickness="0" Padding="0" Click="DismissError_Click" FontSize="22" AutomationProperties.Name="Закрыть уведомление"/></Grid></Border>
|
||||
<Grid Grid.Row="2"><Grid.ColumnDefinitions><ColumnDefinition Width="415"/><ColumnDefinition Width="20"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource Card}">
|
||||
<Grid Margin="29,25"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
|
||||
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="ПОДКЛЮЧЕНИЕ" FontSize="11" FontWeight="SemiBold" Foreground="#93A4C4"/><Border Grid.Column="1" Background="#1D3146" CornerRadius="7" Padding="8,3"><StackPanel Orientation="Horizontal"><Ellipse x:Name="StatusDot" Width="6" Height="6" Fill="#7E90AE" Margin="0,0,6,0"/><TextBlock x:Name="StatusBadge" Text="Ожидание службы" FontSize="10" Foreground="#B6C7E3"/></StackPanel></Border></Grid>
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center" Margin="0,17,0,12">
|
||||
<Grid Width="222" Height="222" Margin="0,0,0,21">
|
||||
<Ellipse Stroke="#20304A" StrokeThickness="1" Margin="1"/>
|
||||
<Ellipse Stroke="#263D56" StrokeThickness="1" Margin="16"/>
|
||||
<Ellipse x:Name="GlowRing" Fill="#152D44" Margin="32"/>
|
||||
<Button x:Name="ConnectButton" Width="142" Height="142" Click="Connect_Click" Background="#64DFFF" BorderThickness="0" AutomationProperties.Name="Подключить VPN" IsEnabled="False">
|
||||
<Button.Template><ControlTemplate TargetType="Button"><Grid><Ellipse x:Name="PowerBackground" Fill="{TemplateBinding Background}"/><ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/></Grid><ControlTemplate.Triggers><Trigger Property="IsMouseOver" Value="True"><Setter TargetName="PowerBackground" Property="Opacity" Value="0.88"/></Trigger><Trigger Property="IsPressed" Value="True"><Setter TargetName="PowerBackground" Property="Opacity" Value="0.72"/></Trigger><Trigger Property="IsEnabled" Value="False"><Setter TargetName="PowerBackground" Property="Opacity" Value="0.42"/></Trigger><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="PowerBackground" Property="Stroke" Value="White"/><Setter TargetName="PowerBackground" Property="StrokeThickness" Value="3"/></Trigger></ControlTemplate.Triggers></ControlTemplate></Button.Template>
|
||||
<Canvas Width="55" Height="60"><Path Stroke="#0C2037" StrokeThickness="5" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Data="M27.5,3 L27.5,28 M13,12 A23,23 0 1 0 42,12"/></Canvas>
|
||||
</Button>
|
||||
</Grid>
|
||||
<TextBlock x:Name="ConnectionTitle" Text="Подготовка QVPN" HorizontalAlignment="Center" FontSize="24" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="ConnectHint" Text="Подключаемся к службе Windows" Foreground="#93A4C4" TextAlignment="Center" Margin="0,9,0,0" FontSize="13"/>
|
||||
<TextBlock x:Name="DurationText" Text="00:00:00" FontSize="29" FontWeight="Light" Foreground="#D5E8FF" HorizontalAlignment="Center" Margin="0,19,0,0" FontFamily="Consolas"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2">
|
||||
<Border Background="#0C1427" CornerRadius="13" Padding="17,13"><StackPanel><TextBlock Text="ТЕКУЩАЯ ЛОКАЦИЯ" FontSize="10" FontWeight="SemiBold" Foreground="#7289AA"/><TextBlock x:Name="LocationText" Text="Автоматический выбор" FontSize="16" FontWeight="SemiBold" Margin="0,5,0,0" TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"/><TextBlock x:Name="LocationDetails" Text="Выберем доступный сервер с низкой задержкой" FontSize="11" Foreground="#8299BA" Margin="0,4,0,0"/></StackPanel></Border>
|
||||
<TextBlock x:Name="StatusMessage" Text="Проверка установки…" FontSize="12" Foreground="#93A4C4" Margin="3,14,3,0" MinHeight="35"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Style="{StaticResource Card}">
|
||||
<Grid Margin="23,25,23,20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
|
||||
<Grid><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><StackPanel><TextBlock Text="Локации" FontSize="23" FontWeight="SemiBold"/><TextBlock x:Name="ServersCaption" Text="Добавьте подписку Blanc VPN" Foreground="#93A4C4" FontSize="12" Margin="0,5,0,0"/></StackPanel><Button x:Name="RefreshButton" Grid.Column="1" Content="" FontFamily="Segoe MDL2 Assets" Style="{StaticResource SmallButton}" VerticalAlignment="Top" Click="Refresh_Click" ToolTip="Обновить подписку" AutomationProperties.Name="Обновить подписку" IsEnabled="False"/></Grid>
|
||||
<Grid Grid.Row="1" Margin="0,19,0,14"><TextBox x:Name="SearchBox" TextChanged="Search_TextChanged" AutomationProperties.Name="Поиск локации"/><TextBlock Text="Поиск страны или сервера" Margin="13,0,0,0" VerticalAlignment="Center" Foreground="#6C829F" IsHitTestVisible="False"><TextBlock.Style><Style TargetType="TextBlock"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value=""><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></TextBlock.Style></TextBlock></Grid>
|
||||
<Button x:Name="AutoButton" Grid.Row="2" Click="Auto_Click" HorizontalContentAlignment="Stretch" Margin="0,0,0,10" Padding="13,12" IsEnabled="False"><Grid Width="Auto"><Grid.ColumnDefinitions><ColumnDefinition Width="30"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="20" Foreground="#64DFFF" VerticalAlignment="Center"/><StackPanel Grid.Column="1"><TextBlock Text="Автоматически" FontWeight="SemiBold"/><TextBlock Text="Лучший доступный сервер" Foreground="#93A4C4" FontSize="11" Margin="0,3,0,0"/></StackPanel><TextBlock x:Name="AutoCheck" Grid.Column="2" Text="✓" Foreground="#64DFFF" FontSize="20" Margin="13,0,0,0" VerticalAlignment="Center"/></Grid></Button>
|
||||
<Grid Grid.Row="3"><ListBox x:Name="ServerList" Background="Transparent" BorderThickness="0" ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectionChanged="Server_SelectionChanged" AutomationProperties.Name="Серверы Blanc VPN" IsEnabled="False">
|
||||
<ListBox.Template><ControlTemplate TargetType="ListBox"><ScrollViewer Focusable="False" Background="Transparent" Padding="0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"><ItemsPresenter/></ScrollViewer></ControlTemplate></ListBox.Template>
|
||||
<ListBox.ItemContainerStyle><Style TargetType="ListBoxItem"><Setter Property="HorizontalContentAlignment" Value="Stretch"/><Setter Property="Foreground" Value="#E9F1FF"/><Setter Property="Padding" Value="0"/><Setter Property="Margin" Value="0,0,0,7"/><Setter Property="Template"><Setter.Value><ControlTemplate TargetType="ListBoxItem"><Border x:Name="Row" Background="#141F36" BorderBrush="#24334C" BorderThickness="1" CornerRadius="12" Padding="12,12"><ContentPresenter/></Border><ControlTemplate.Triggers><Trigger Property="IsSelected" Value="True"><Setter TargetName="Row" Property="Background" Value="#203950"/><Setter TargetName="Row" Property="BorderBrush" Value="#5EC9E8"/></Trigger><Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Row" Property="Background" Value="#20304A"/></Trigger></ControlTemplate.Triggers></ControlTemplate></Setter.Value></Setter></Style></ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate><DataTemplate><Grid><Grid.ColumnDefinitions><ColumnDefinition Width="35"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><Border Background="#253851" CornerRadius="8" Width="27" Height="29" HorizontalAlignment="Left"><TextBlock Text="{Binding Symbol}" Foreground="#B5CEE7" FontSize="11" FontWeight="Bold" VerticalAlignment="Center" HorizontalAlignment="Center"/></Border><TextBlock Grid.Column="1" Text="{Binding Name}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" FontSize="13" Margin="0,0,8,0"/><TextBlock Grid.Column="2" Text="{Binding Latency}" Foreground="{Binding LatencyColor}" FontSize="11" VerticalAlignment="Center"/></Grid></DataTemplate></ListBox.ItemTemplate>
|
||||
</ListBox><TextBlock x:Name="EmptyServers" Text="Вставьте ссылку подписки Blanc VPN,
и ваши локации появятся здесь." Foreground="#7D93B1" FontSize="13" TextAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20"/></Grid>
|
||||
<Grid Grid.Row="4" Margin="0,12,0,0"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock x:Name="UpdatedText" Text="Подписка ещё не добавлена" FontSize="10" Foreground="#7289AA" VerticalAlignment="Center"/><Button x:Name="MeasureButton" Grid.Column="1" Content="Проверить задержку" Style="{StaticResource SmallButton}" Click="Measure_Click" IsEnabled="False"/></Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Grid Grid.Row="3" Margin="0,18,0,0"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><StackPanel Orientation="Horizontal"><TextBlock Text="" FontFamily="Segoe MDL2 Assets" Foreground="#718CAE" FontSize="12" VerticalAlignment="Center"/><TextBlock x:Name="FooterText" Text="Соединение сохраняется, когда окно закрыто" Foreground="#718CAE" FontSize="11" Margin="8,0,0,0"/></StackPanel><TextBlock x:Name="RoutingText" Grid.Column="1" Text="Российские сайты напрямую" Foreground="#718CAE" FontSize="11"/></Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Win32;
|
||||
using QVPN.Core;
|
||||
using Forms = System.Windows.Forms;
|
||||
using Drawing = System.Drawing;
|
||||
using Color = System.Windows.Media.Color;
|
||||
using ColorConverter = System.Windows.Media.ColorConverter;
|
||||
using FontFamily = System.Windows.Media.FontFamily;
|
||||
using Orientation = System.Windows.Controls.Orientation;
|
||||
using Button = System.Windows.Controls.Button;
|
||||
using CheckBox = System.Windows.Controls.CheckBox;
|
||||
using Clipboard = System.Windows.Clipboard;
|
||||
using TextBox = System.Windows.Controls.TextBox;
|
||||
|
||||
namespace QVPN.App;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly bool _preview;
|
||||
private readonly string? _screenshot;
|
||||
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) };
|
||||
private readonly CancellationTokenSource _closing = new();
|
||||
private readonly Forms.NotifyIcon? _tray;
|
||||
private readonly Drawing.Icon? _trayIcon;
|
||||
private Forms.ToolStripMenuItem? _trayDisconnect;
|
||||
private Status? _status;
|
||||
private bool _applyingStatus, _busy, _serviceAvailable, _exitRequested, _hideNoticeShown, _loaded;
|
||||
private List<ServerRow> _rows = [];
|
||||
private const string StartupName = "QVPN";
|
||||
|
||||
public MainWindow(bool preview, string? screenshot)
|
||||
{
|
||||
InitializeComponent();
|
||||
_preview = preview;
|
||||
_screenshot = screenshot;
|
||||
if (!preview && screenshot is null)
|
||||
{
|
||||
_trayIcon = CreateIcon();
|
||||
Icon = System.Windows.Interop.Imaging.CreateBitmapSourceFromHIcon(_trayIcon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
|
||||
_tray = new Forms.NotifyIcon { Icon = _trayIcon, Visible = true, Text = "QVPN — ожидание службы" };
|
||||
var menu = new Forms.ContextMenuStrip();
|
||||
menu.Items.Add("Открыть QVPN", null, (_, _) => Dispatcher.BeginInvoke(RestoreWindow));
|
||||
_trayDisconnect = new Forms.ToolStripMenuItem("Отключить VPN", null, (_, _) => Dispatcher.BeginInvoke(async () => await SendActionAsync(new Request("disconnect"))));
|
||||
menu.Items.Add(_trayDisconnect);
|
||||
menu.Items.Add(new Forms.ToolStripSeparator());
|
||||
menu.Items.Add("Закрыть интерфейс (VPN продолжит работу)", null, (_, _) => Dispatcher.BeginInvoke(ExitInterface));
|
||||
_tray.ContextMenuStrip = menu;
|
||||
_tray.DoubleClick += (_, _) => Dispatcher.BeginInvoke(RestoreWindow);
|
||||
}
|
||||
Loaded += Window_Loaded;
|
||||
_timer.Tick += async (_, _) => { UpdateDuration(); if (!_preview) await PollAsync(); };
|
||||
}
|
||||
|
||||
private async void Window_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
if (_preview)
|
||||
{
|
||||
PreviewBadge.Visibility = Visibility.Visible;
|
||||
ApplyStatus(new Status("disconnected", "Демонстрация интерфейса. VPN отключён; все локации показаны для примера.", false, null, null, null, 0, null, true, true, "", DateTimeOffset.Now,
|
||||
[new("demo-nl", "Нидерланды · Amsterdam", 42), new("demo-de", "Германия · Frankfurt", 58), new("demo-fi", "Финляндия · Helsinki", 35), new("demo-se", "Швеция · Stockholm", 67), new("demo-us", "США · New York", 126), new("demo-uk", "Великобритания · London", 73)], []));
|
||||
StatusBadge.Text = "Демонстрация";
|
||||
ConnectHint.Text = "Демонстрация · VPN отключён";
|
||||
FooterText.Text = "Демонстрация интерфейса · соединение не установлено";
|
||||
SetControlsEnabled();
|
||||
if (_screenshot is not null) _ = Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, SaveSnapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PollAsync();
|
||||
if (_screenshot is not null)
|
||||
{
|
||||
FooterText.Text = $"Отрисовка интерфейса по статусу службы · {DateTimeOffset.Now:HH:mm:ss}";
|
||||
_ = Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, SaveSnapshot);
|
||||
}
|
||||
else _timer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void StartInTray() { Show(); Hide(); }
|
||||
public void RestoreWindow() { Show(); WindowState = WindowState.Normal; Activate(); }
|
||||
public void ShowError(string message) { ErrorText.Text = message; ErrorBanner.Visibility = Visibility.Visible; }
|
||||
|
||||
private async Task PollAsync()
|
||||
{
|
||||
if (_closing.IsCancellationRequested || !await _requestGate.WaitAsync(0)) return;
|
||||
try
|
||||
{
|
||||
var response = await PipeClient.SendAsync(new Request("status"), _closing.Token);
|
||||
if (response.Status is { } status) ApplyStatus(status);
|
||||
else SetUnavailable();
|
||||
}
|
||||
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { }
|
||||
catch (Exception error) { UiLog.Write(error); SetUnavailable(); }
|
||||
finally { _requestGate.Release(); }
|
||||
}
|
||||
|
||||
private void SetUnavailable()
|
||||
{
|
||||
_serviceAvailable = false;
|
||||
ConnectionTitle.Text = "Служба не запущена";
|
||||
ConnectHint.Text = "Для VPN нужна служба QVPN";
|
||||
StatusBadge.Text = "Нет службы";
|
||||
StatusDot.Fill = Brush("#FFBE76");
|
||||
StatusMessage.Text = "Запустите установщик QVPN-Setup.exe. Он установит службу Windows и необходимые компоненты. Если QVPN уже установлен — откройте диагностику или повторите установку.";
|
||||
GlowRing.Fill = Brush("#292C34");
|
||||
ConnectButton.Background = Brush("#8099B6");
|
||||
DurationText.Text = "—";
|
||||
if (_tray is not null) _tray.Text = "QVPN — нет связи со службой";
|
||||
SetControlsEnabled();
|
||||
}
|
||||
|
||||
private void ApplyStatus(Status status)
|
||||
{
|
||||
_status = status;
|
||||
_serviceAvailable = true;
|
||||
_applyingStatus = true;
|
||||
try
|
||||
{
|
||||
bool connected = status.State == "connected";
|
||||
bool starting = status.DesiredConnected && !connected;
|
||||
string label = status.State switch { "connected" => "Подключено", "connecting" => "Подключение", "recovering" => "Восстановление", "waiting-network" => "Ожидание сети", "error" => "Ошибка соединения", _ => "Отключено" };
|
||||
ConnectionTitle.Text = label;
|
||||
StatusBadge.Text = label;
|
||||
StatusDot.Fill = Brush(connected ? "#64DFFF" : starting ? "#FFCA7C" : "#7E90AE");
|
||||
ConnectButton.Background = Brush(connected ? "#64DFFF" : starting ? "#E8B66B" : "#64DFFF");
|
||||
GlowRing.Fill = Brush(connected ? "#193F55" : "#152D44");
|
||||
System.Windows.Automation.AutomationProperties.SetName(ConnectButton, status.DesiredConnected ? "Отключить VPN" : "Подключить VPN");
|
||||
ConnectHint.Text = connected ? "Нажмите, чтобы отключить" : starting ? "Восстановим автоматически · нажмите для отмены" : status.Nodes.Count > 0 ? "Нажмите, чтобы подключиться" : "Добавьте подписку Blanc VPN";
|
||||
StatusMessage.Text = status.Message;
|
||||
LocationText.Text = status.ActiveName ?? status.Nodes.FirstOrDefault(n => n.Id == status.SelectedId)?.Name ?? "Автоматический выбор";
|
||||
LocationDetails.Text = status.SelectedId.Length == 0 ? "Автоматический выбор доступного сервера" : "Выбранный сервер · автопереход при сбое";
|
||||
if (!status.AutoFailover && status.SelectedId.Length > 0) LocationDetails.Text = "Выбранный сервер · автопереход выключен";
|
||||
ServersCaption.Text = status.Nodes.Count == 0 ? "Добавьте подписку Blanc VPN" : $"{status.Nodes.Count} серверов в вашей подписке";
|
||||
UpdatedText.Text = status.UpdatedAt is { } updated ? $"Обновлено {updated.ToLocalTime():dd.MM · HH:mm}" : "Подписка ещё не добавлена";
|
||||
AutoCheck.Visibility = status.SelectedId.Length == 0 ? Visibility.Visible : Visibility.Hidden;
|
||||
AutoButton.BorderBrush = Brush(status.SelectedId.Length == 0 ? "#5EC9E8" : "#334462");
|
||||
RoutingText.Text = status.RussianDirect ? "Российские сайты напрямую" : "Весь публичный трафик через VPN";
|
||||
var rows = status.Nodes.Select(node => new ServerRow(node)).ToList();
|
||||
bool rowsChanged = rows.Count != _rows.Count || rows.Where((row, index) => row.Id != _rows[index].Id || row.Name != _rows[index].Name || row.Latency != _rows[index].Latency).Any();
|
||||
if (rowsChanged) { _rows = rows; RefilterServers(); }
|
||||
else ServerList.SelectedItem = ServerList.Items.OfType<ServerRow>().FirstOrDefault(row => row.Id == status.SelectedId);
|
||||
UpdateDuration();
|
||||
if (_tray is not null) _tray.Text = "QVPN — " + label;
|
||||
SetControlsEnabled();
|
||||
}
|
||||
finally { _applyingStatus = false; }
|
||||
}
|
||||
|
||||
private void SetControlsEnabled()
|
||||
{
|
||||
bool enabled = _serviceAvailable && !_busy;
|
||||
bool hasNodes = _status?.Nodes.Count > 0;
|
||||
ConnectButton.IsEnabled = enabled && (hasNodes || _status?.DesiredConnected == true);
|
||||
RefreshButton.IsEnabled = enabled && hasNodes;
|
||||
AutoButton.IsEnabled = enabled && hasNodes;
|
||||
ServerList.IsEnabled = enabled && hasNodes;
|
||||
MeasureButton.IsEnabled = enabled && hasNodes;
|
||||
if (_trayDisconnect is not null) _trayDisconnect.Enabled = enabled && _status?.DesiredConnected == true;
|
||||
}
|
||||
|
||||
private void UpdateDuration()
|
||||
{
|
||||
if (!_serviceAvailable && _status is not null) { DurationText.Text = "—"; return; }
|
||||
if (_status?.ConnectedSince is { } since && _status.State == "connected")
|
||||
{
|
||||
var duration = DateTimeOffset.Now - since;
|
||||
if (duration < TimeSpan.Zero) duration = TimeSpan.Zero;
|
||||
DurationText.Text = $"{(long)duration.TotalHours:00}:{duration.Minutes:00}:{duration.Seconds:00}";
|
||||
}
|
||||
else DurationText.Text = "00:00:00";
|
||||
}
|
||||
|
||||
private async Task<bool> SendActionAsync(Request request)
|
||||
{
|
||||
if (_preview) { ShowError("Это демонстрация интерфейса. VPN не подключается."); return false; }
|
||||
if (_screenshot is not null) return false;
|
||||
if (_busy) return false;
|
||||
_busy = true;
|
||||
SetControlsEnabled();
|
||||
await _requestGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var response = await PipeClient.SendAsync(request, _closing.Token);
|
||||
if (response.Status is { } status) ApplyStatus(status);
|
||||
if (!response.Ok) ShowError(response.Error ?? "Не удалось выполнить действие. Откройте диагностику.");
|
||||
else ErrorBanner.Visibility = Visibility.Collapsed;
|
||||
return response.Ok;
|
||||
}
|
||||
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { return false; }
|
||||
catch (Exception error) { UiLog.Write(error); SetUnavailable(); ShowError("Нет ответа от службы QVPN. Проверьте установку службы. Подключение, если оно уже работает, управляется службой независимо от окна."); return false; }
|
||||
finally { _requestGate.Release(); _busy = false; SetControlsEnabled(); }
|
||||
}
|
||||
|
||||
public async Task<bool> ImportAsync(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) { ShowError("Вставьте ссылку подписки Blanc VPN."); return false; }
|
||||
return await SendActionAsync(new Request("import", url.Trim()));
|
||||
}
|
||||
|
||||
private async void Connect_Click(object sender, RoutedEventArgs e) => await SendActionAsync(new Request(_status?.DesiredConnected == true ? "disconnect" : "connect"));
|
||||
private async void Refresh_Click(object sender, RoutedEventArgs e) => await SendActionAsync(new Request("refresh"));
|
||||
private async void Auto_Click(object sender, RoutedEventArgs e) => await SendActionAsync(new Request("select", ""));
|
||||
private async void Measure_Click(object sender, RoutedEventArgs e) => await SendActionAsync(new Request("measure"));
|
||||
private async void Server_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (!_applyingStatus && ServerList.SelectedItem is ServerRow row && row.Id != _status?.SelectedId)
|
||||
await SendActionAsync(new Request("select", row.Id));
|
||||
}
|
||||
private void Search_TextChanged(object sender, TextChangedEventArgs e) { if (ServerList is not null) RefilterServers(); }
|
||||
private void RefilterServers()
|
||||
{
|
||||
bool previous = _applyingStatus;
|
||||
_applyingStatus = true;
|
||||
try
|
||||
{
|
||||
var filtered = _rows.Where(row => row.Name.Contains(SearchBox.Text.Trim(), StringComparison.CurrentCultureIgnoreCase)).ToList();
|
||||
ServerList.ItemsSource = filtered;
|
||||
ServerList.SelectedItem = filtered.FirstOrDefault(row => row.Id == _status?.SelectedId);
|
||||
EmptyServers.Visibility = filtered.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
EmptyServers.Text = _rows.Count == 0 ? "Вставьте ссылку подписки Blanc VPN,\nи ваши локации появятся здесь." : "По этому запросу локаций нет.";
|
||||
}
|
||||
finally { _applyingStatus = previous; }
|
||||
}
|
||||
private void DismissError_Click(object sender, RoutedEventArgs e) => ErrorBanner.Visibility = Visibility.Collapsed;
|
||||
|
||||
private void Subscription_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = CreateDialog("Подписка Blanc VPN", 550);
|
||||
var content = DialogContent("Подписка Blanc VPN", "Вставьте вашу ссылку подписки. Она хранится службой в защищённом виде и не отображается в интерфейсе.");
|
||||
var input = new PasswordBox { Margin = new Thickness(0, 21, 0, 12) };
|
||||
System.Windows.Automation.AutomationProperties.SetName(input, "Ссылка подписки Blanc VPN");
|
||||
content.Children.Add(input);
|
||||
var hint = new TextBlock { Text = "Поддерживается только подписка Blanc VPN.", Foreground = Brush("#93A4C4"), FontSize = 12, Margin = new Thickness(0, 0, 0, 20) };
|
||||
content.Children.Add(hint);
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = System.Windows.HorizontalAlignment.Right };
|
||||
var paste = new Button { Content = "Вставить", Margin = new Thickness(0, 0, 10, 0) };
|
||||
paste.Click += (_, _) => { try { if (Clipboard.ContainsText()) input.Password = Clipboard.GetText().Trim(); } catch (Exception error) { UiLog.Write(error); hint.Text = "Буфер обмена занят. Повторите вставку."; } };
|
||||
var save = new Button { Content = "Добавить подписку", Style = (Style)FindResource("PrimaryButton"), IsDefault = true };
|
||||
save.Click += async (_, _) =>
|
||||
{
|
||||
save.IsEnabled = paste.IsEnabled = input.IsEnabled = false;
|
||||
try { if (await ImportAsync(input.Password)) { input.Clear(); dialog.Close(); } else hint.Text = ErrorText.Text; }
|
||||
finally { save.IsEnabled = paste.IsEnabled = input.IsEnabled = true; }
|
||||
};
|
||||
buttons.Children.Add(paste); buttons.Children.Add(save); content.Children.Add(buttons);
|
||||
dialog.Content = content;
|
||||
dialog.Loaded += (_, _) => input.Focus();
|
||||
dialog.ShowDialog();
|
||||
input.Clear();
|
||||
}
|
||||
|
||||
private void Settings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = CreateDialog("Настройки QVPN", 600);
|
||||
var content = DialogContent("Настройки", "Соединением управляет служба Windows. Она работает независимо от окна и автоматически восстанавливает VPN после сбоя.");
|
||||
var russian = new CheckBox { Content = "Российские сайты открывать напрямую", IsChecked = _status?.RussianDirect ?? true, IsEnabled = _serviceAvailable && !_preview, Margin = new Thickness(0, 23, 0, 5) };
|
||||
var failover = new CheckBox { Content = "Автоматически менять сервер при сбое", IsChecked = _status?.AutoFailover ?? true, IsEnabled = _serviceAvailable && !_preview };
|
||||
var startup = new CheckBox { Content = "Запускать интерфейс при входе в Windows", IsChecked = IsStartupEnabled(), IsEnabled = !_preview };
|
||||
content.Children.Add(russian);
|
||||
content.Children.Add(new TextBlock { Text = "Маршрутизация учитывает домены .ru/.рф и российские IP-адреса.", Foreground = Brush("#93A4C4"), FontSize = 12, Margin = new Thickness(20, 2, 0, 12) });
|
||||
content.Children.Add(failover);
|
||||
content.Children.Add(new TextBlock { Text = "Если выбранная локация недоступна, QVPN восстановит связь через другой сервер вашей подписки.", Foreground = Brush("#93A4C4"), FontSize = 12, Margin = new Thickness(20, 2, 0, 12) });
|
||||
content.Children.Add(startup);
|
||||
content.Children.Add(new TextBlock { Text = "Окно запускается в области уведомлений. Служба запускается вместе с Windows; последнее состояние подключения сохраняется.", Foreground = Brush("#93A4C4"), FontSize = 12, Margin = new Thickness(20, 2, 0, 20) });
|
||||
var save = new Button { Content = "Сохранить", Style = (Style)FindResource("PrimaryButton"), HorizontalAlignment = System.Windows.HorizontalAlignment.Right, IsDefault = true };
|
||||
var feedback = new TextBlock { Foreground = Brush("#FFCF90"), FontSize = 12, Margin = new Thickness(0, 0, 0, 12) };
|
||||
content.Children.Add(feedback); content.Children.Add(save);
|
||||
save.Click += async (_, _) =>
|
||||
{
|
||||
save.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (_preview) { dialog.Close(); return; }
|
||||
if (_serviceAvailable && _status is not null)
|
||||
{
|
||||
if (russian.IsChecked != _status.RussianDirect && !await SendActionAsync(new Request("russian", Enabled: russian.IsChecked == true))) { feedback.Text = ErrorText.Text; return; }
|
||||
if (failover.IsChecked != _status.AutoFailover && !await SendActionAsync(new Request("failover", Enabled: failover.IsChecked == true))) { feedback.Text = ErrorText.Text; return; }
|
||||
}
|
||||
SetStartupEnabled(startup.IsChecked == true);
|
||||
dialog.Close();
|
||||
}
|
||||
catch (Exception error) { UiLog.Write(error); feedback.Text = "Windows не разрешила изменить автозапуск интерфейса."; }
|
||||
finally { save.IsEnabled = true; }
|
||||
};
|
||||
dialog.Content = content;
|
||||
dialog.ShowDialog();
|
||||
}
|
||||
|
||||
private void Diagnostics_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = CreateDialog("Диагностика QVPN", 760);
|
||||
var content = DialogContent("Диагностика", "Журнал помогает проверить восстановление соединения. Ссылка подписки и ключи не включаются в отчёт.");
|
||||
string report = DiagnosticsReport();
|
||||
var details = new TextBox { Text = report, IsReadOnly = true, TextWrapping = TextWrapping.Wrap, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Height = 330, Margin = new Thickness(0, 18, 0, 18), FontFamily = new FontFamily("Consolas"), FontSize = 12 };
|
||||
content.Children.Add(details);
|
||||
var save = new Button { Content = "Сохранить отчёт…", HorizontalAlignment = System.Windows.HorizontalAlignment.Right };
|
||||
save.Click += (_, _) =>
|
||||
{
|
||||
var picker = new Microsoft.Win32.SaveFileDialog { Filter = "Текстовый отчёт (*.txt)|*.txt", FileName = $"QVPN-diagnostics-{DateTime.Now:yyyyMMdd-HHmm}.txt" };
|
||||
if (picker.ShowDialog(dialog) == true)
|
||||
{
|
||||
try { File.WriteAllText(picker.FileName, report, Encoding.UTF8); }
|
||||
catch (Exception error) { UiLog.Write(error); ShowError("Не удалось сохранить отчёт. Выберите другую папку."); }
|
||||
}
|
||||
};
|
||||
content.Children.Add(save); dialog.Content = content; dialog.ShowDialog();
|
||||
}
|
||||
|
||||
private string DiagnosticsReport()
|
||||
{
|
||||
var report = new StringBuilder("QVPN 1.0 · диагностика\n");
|
||||
report.AppendLine($"Время: {DateTimeOffset.Now:O}");
|
||||
report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}");
|
||||
if (_status is { } status)
|
||||
{
|
||||
report.AppendLine($"Состояние: {status.State}");
|
||||
report.AppendLine($"VPN запрошен: {status.DesiredConnected}");
|
||||
report.AppendLine($"Сообщение: {status.Message}");
|
||||
report.AppendLine($"Серверов: {status.Nodes.Count}; перезапусков: {status.Restarts}; PID ядра: {status.EnginePid}");
|
||||
report.AppendLine($"Российские сайты напрямую: {status.RussianDirect}; автопереход: {status.AutoFailover}");
|
||||
report.AppendLine("\nСобытия службы:");
|
||||
foreach (var entry in status.Events) report.AppendLine(entry);
|
||||
}
|
||||
else report.AppendLine("Запустите установщик QVPN-Setup.exe для установки службы Windows.");
|
||||
report.AppendLine($"\nЖурнал интерфейса: {UiLog.DirectoryPath}");
|
||||
// Never emit URLs supplied by the service, even if a future event accidentally includes one.
|
||||
return Regex.Replace(report.ToString(), @"https?://[^\s]+", "[ссылка скрыта]", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private Window CreateDialog(string title, double width) => new()
|
||||
{
|
||||
Title = title, Owner = this, Width = width, SizeToContent = SizeToContent.Height,
|
||||
ResizeMode = ResizeMode.NoResize, WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false, Background = Brush("#121B31")
|
||||
};
|
||||
private static StackPanel DialogContent(string title, string description)
|
||||
{
|
||||
var content = new StackPanel { Margin = new Thickness(28) };
|
||||
content.Children.Add(new TextBlock { Text = title, FontSize = 24, FontWeight = FontWeights.SemiBold });
|
||||
content.Children.Add(new TextBlock { Text = description, FontSize = 13, Foreground = Brush("#93A4C4"), Margin = new Thickness(0, 10, 0, 0) });
|
||||
return content;
|
||||
}
|
||||
|
||||
private static bool IsStartupEnabled()
|
||||
{
|
||||
try { using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); return key?.GetValue(StartupName) is string value && value.Contains("QVPN", StringComparison.OrdinalIgnoreCase); }
|
||||
catch { return false; }
|
||||
}
|
||||
private static void SetStartupEnabled(bool enabled)
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true);
|
||||
if (enabled) key.SetValue(StartupName, $"\"{Environment.ProcessPath}\" --tray", RegistryValueKind.String);
|
||||
else key.DeleteValue(StartupName, false);
|
||||
}
|
||||
|
||||
private void Window_StateChanged(object? sender, EventArgs e) { if (WindowState == WindowState.Minimized && !_preview) Hide(); }
|
||||
private void Window_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_exitRequested || _preview) { PrepareExit(); if (_preview) _ = Dispatcher.BeginInvoke(() => System.Windows.Application.Current.Shutdown()); return; }
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
if (!_hideNoticeShown && _tray is not null)
|
||||
{
|
||||
_hideNoticeShown = true;
|
||||
_tray.ShowBalloonTip(4000, "QVPN работает в фоне", "Соединение остаётся активным. Чтобы открыть окно, дважды нажмите значок QVPN рядом с часами.", Forms.ToolTipIcon.Info);
|
||||
}
|
||||
}
|
||||
public void PrepareExit()
|
||||
{
|
||||
if (_exitRequested) return;
|
||||
_exitRequested = true;
|
||||
_timer.Stop();
|
||||
_closing.Cancel();
|
||||
if (_tray is not null) { _tray.Visible = false; _tray.Dispose(); }
|
||||
_trayIcon?.Dispose();
|
||||
}
|
||||
private void ExitInterface() { PrepareExit(); System.Windows.Application.Current.Shutdown(); }
|
||||
|
||||
private void SaveSnapshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateLayout();
|
||||
var image = new RenderTargetBitmap((int)ActualWidth, (int)ActualHeight, 96, 96, PixelFormats.Pbgra32);
|
||||
image.Render(this);
|
||||
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image));
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(_screenshot!));
|
||||
if (directory is not null) Directory.CreateDirectory(directory);
|
||||
using var stream = File.Create(_screenshot!);
|
||||
encoder.Save(stream);
|
||||
System.Windows.Application.Current.Shutdown(_preview || _serviceAvailable ? 0 : 1);
|
||||
}
|
||||
catch (Exception error) { UiLog.Write(error); ShowError("Не удалось сохранить отрисовку интерфейса."); System.Windows.Application.Current.Shutdown(2); }
|
||||
}
|
||||
|
||||
private static SolidColorBrush Brush(string color) => new((Color)ColorConverter.ConvertFromString(color));
|
||||
private static Drawing.Icon CreateIcon()
|
||||
{
|
||||
using var bitmap = new Drawing.Bitmap(32, 32);
|
||||
using var graphics = Drawing.Graphics.FromImage(bitmap);
|
||||
graphics.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
graphics.Clear(Drawing.Color.Transparent);
|
||||
using var fill = new Drawing.SolidBrush(Drawing.Color.FromArgb(100, 223, 255));
|
||||
graphics.FillEllipse(fill, 1, 1, 29, 29);
|
||||
using var pen = new Drawing.Pen(Drawing.Color.FromArgb(10, 26, 49), 3);
|
||||
graphics.DrawEllipse(pen, 8, 7, 15, 16);
|
||||
graphics.DrawLine(pen, 19, 20, 26, 26);
|
||||
IntPtr handle = bitmap.GetHicon();
|
||||
try { using var native = Drawing.Icon.FromHandle(handle); return (Drawing.Icon)native.Clone(); }
|
||||
finally { DestroyIcon(handle); }
|
||||
}
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
}
|
||||
|
||||
public sealed class ServerRow
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public string Latency { get; }
|
||||
public System.Windows.Media.Brush LatencyColor { get; }
|
||||
public string Symbol { get; }
|
||||
public ServerRow(NodeView node)
|
||||
{
|
||||
Id = node.Id; Name = node.Name;
|
||||
Latency = node.LatencyMs is { } ms ? (ms < 0 ? "недоступен" : $"{ms} мс") : "—";
|
||||
LatencyColor = new SolidColorBrush(node.LatencyMs is { } delay && delay >= 0 ? delay < 100 ? Color.FromRgb(124, 217, 173) : Color.FromRgb(239, 196, 124) : Color.FromRgb(126, 147, 177));
|
||||
string name = node.Name.ToLowerInvariant();
|
||||
Symbol = name.Contains("нидер") || name.Contains("nether") || name.Contains("amsterdam") ? "NL" : name.Contains("герман") || name.Contains("german") || name.Contains("frankfurt") ? "DE" : name.Contains("финля") || name.Contains("finland") || name.Contains("helsinki") ? "FI" : name.Contains("швец") || name.Contains("sweden") || name.Contains("stockholm") ? "SE" : name.Contains("сша") || name.Contains("united states") || name.Contains("new york") ? "US" : name.Contains("великобрит") || name.Contains("united kingdom") || name.Contains("london") ? "GB" : "↗";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>QVPN</AssemblyName>
|
||||
<RootNamespace>QVPN.App</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="..\QVPN.Core\QVPN.Core.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="QVPN.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false" /></requestedPrivileges></security></trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application></compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace QVPN.Core;
|
||||
|
||||
public static class ConfigBuilder
|
||||
{
|
||||
public static JsonObject Build(VpnNode node, bool tun, bool russianDirect, string rulesDirectory, int port = Constants.ProxyPort)
|
||||
{
|
||||
var root = new JsonObject
|
||||
{
|
||||
["log"] = new JsonObject { ["level"] = "error", ["timestamp"] = true },
|
||||
["dns"] = new JsonObject
|
||||
{
|
||||
["servers"] = new JsonArray(new JsonObject { ["type"] = "https", ["tag"] = "remote-dns", ["server"] = "1.1.1.1", ["path"] = "/dns-query", ["detour"] = "proxy", ["tls"] = new JsonObject { ["enabled"] = true, ["server_name"] = "cloudflare-dns.com" } }),
|
||||
["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" })
|
||||
};
|
||||
if (tun) ((JsonArray)root["inbounds"]!).Add(new JsonObject
|
||||
{
|
||||
["type"] = "tun", ["tag"] = "tun-in", ["interface_name"] = "QVPN", ["address"] = new JsonArray("172.29.255.1/30", "fdfe:7176:706e::1/126"),
|
||||
["mtu"] = 1500, ["auto_route"] = true, ["strict_route"] = true, ["stack"] = "mixed", ["dns_mode"] = "hijack"
|
||||
});
|
||||
var rules = new JsonArray(new JsonObject { ["action"] = "sniff" }, new JsonObject { ["protocol"] = "dns", ["action"] = "hijack-dns" },
|
||||
new JsonObject { ["inbound"] = "health-in", ["action"] = "route", ["outbound"] = "proxy" },
|
||||
new JsonObject { ["ip_is_private"] = true, ["action"] = "route", ["outbound"] = "direct" });
|
||||
var route = new JsonObject { ["auto_detect_interface"] = true, ["final"] = "proxy", ["default_domain_resolver"] = "remote-dns", ["rules"] = rules };
|
||||
if (russianDirect)
|
||||
{
|
||||
rules.Add(new JsonObject { ["domain_suffix"] = new JsonArray("ru", "su", "xn--p1ai"), ["action"] = "route", ["outbound"] = "direct" });
|
||||
var sets = new JsonArray();
|
||||
foreach (var (tag, filename) in new[] { ("geoip-ru", "geoip-ru.srs"), ("geosite-ru", "geosite-category-ru.srs") })
|
||||
{
|
||||
var path = Path.Combine(rulesDirectory, filename);
|
||||
if (File.Exists(path)) { sets.Add(new JsonObject { ["type"] = "local", ["tag"] = tag, ["format"] = "binary", ["path"] = path }); rules.Add(new JsonObject { ["rule_set"] = tag, ["action"] = "route", ["outbound"] = "direct" }); }
|
||||
}
|
||||
if (sets.Count > 0) route["rule_set"] = sets;
|
||||
}
|
||||
root["route"] = route;
|
||||
return root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QVPN.Core;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const string ServiceName = "QVPNService";
|
||||
public const string PipeName = "QVPN.Control.v1";
|
||||
public const int ProxyPort = 17891;
|
||||
public static string DataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN");
|
||||
public static JsonSerializerOptions Json { get; } = new() { PropertyNameCaseInsensitive = true, WriteIndented = false };
|
||||
}
|
||||
public record VpnNode(string Id, string Name, string Host, int Port, string Uuid, string ServerName, string PublicKey, string ShortId, string Fingerprint, string Flow);
|
||||
public record NodeView(string Id, string Name, int? LatencyMs);
|
||||
public sealed class Settings
|
||||
{
|
||||
public string SubscriptionUrl { get; set; } = "";
|
||||
public List<VpnNode> Nodes { get; set; } = [];
|
||||
public bool DesiredConnected { get; set; }
|
||||
public bool RussianDirect { get; set; } = true;
|
||||
public bool AutoFailover { get; set; } = true;
|
||||
public string SelectedId { 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);
|
||||
public record Response(bool Ok, string? Error, Status? Status);
|
||||
|
||||
public sealed class SecureStore(string directory)
|
||||
{
|
||||
private readonly string path = Path.Combine(directory, "settings.dpapi");
|
||||
public Settings Load()
|
||||
{
|
||||
if (!File.Exists(path)) return new();
|
||||
return JsonSerializer.Deserialize<Settings>(ProtectedData.Unprotect(File.ReadAllBytes(path), null, DataProtectionScope.LocalMachine), Constants.Json)
|
||||
?? throw new InvalidDataException("Повреждены настройки QVPN.");
|
||||
}
|
||||
public void Save(Settings settings)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
byte[] plain = JsonSerializer.SerializeToUtf8Bytes(settings, Constants.Json);
|
||||
try
|
||||
{
|
||||
byte[] encrypted = ProtectedData.Protect(plain, null, DataProtectionScope.LocalMachine);
|
||||
string temp = path + ".new";
|
||||
using (var file = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) { file.Write(encrypted); file.Flush(true); }
|
||||
if (File.Exists(path)) File.Replace(temp, path, path + ".backup", true); else File.Move(temp, path);
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(plain); }
|
||||
}
|
||||
}
|
||||
|
||||
public static class SafeError
|
||||
{
|
||||
public static string Describe(Exception e) => e switch
|
||||
{
|
||||
HttpRequestException => "Сервер подписки недоступен. Сохранённые локации остаются доступны.",
|
||||
OperationCanceledException => "Превышено время ожидания. Повторите действие.",
|
||||
System.ComponentModel.Win32Exception => "Windows не разрешила запуск VPN. Проверьте установку службы.",
|
||||
InvalidDataException or ArgumentException => e.Message,
|
||||
_ => "Ошибка QVPN: " + e.GetType().Name + ". Подробности в диагностике."
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QVPN.Core;
|
||||
|
||||
public static class PipeClient
|
||||
{
|
||||
public static async Task<Response> SendAsync(Request request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(40));
|
||||
using var pipe = new NamedPipeClientStream(".", Constants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous, System.Security.Principal.TokenImpersonationLevel.Identification);
|
||||
await pipe.ConnectAsync(2500, timeout.Token);
|
||||
await Wire.WriteAsync(pipe, request, timeout.Token);
|
||||
return await Wire.ReadAsync<Response>(pipe, timeout.Token);
|
||||
}
|
||||
}
|
||||
public static class Wire
|
||||
{
|
||||
public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken token)
|
||||
{
|
||||
byte[] header = new byte[4]; await stream.ReadExactlyAsync(header, token);
|
||||
int size = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(header);
|
||||
if (size is <= 0 or > 256 * 1024) throw new InvalidDataException("Недопустимый запрос QVPN.");
|
||||
byte[] data = new byte[size]; await stream.ReadExactlyAsync(data, token);
|
||||
return JsonSerializer.Deserialize<T>(data, Constants.Json) ?? throw new InvalidDataException("Пустой запрос.");
|
||||
}
|
||||
public static async Task WriteAsync<T>(Stream stream, T data, CancellationToken token)
|
||||
{
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(data, Constants.Json);
|
||||
if (bytes.Length > 256 * 1024) throw new InvalidDataException("Недопустимый запрос QVPN.");
|
||||
byte[] header = new byte[4]; System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(header, bytes.Length);
|
||||
await stream.WriteAsync(header, token); await stream.WriteAsync(bytes, token); await stream.FlushAsync(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" /></ItemGroup></Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace QVPN.Core;
|
||||
|
||||
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 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 Reset() { Stable(); NextAttempt = DateTimeOffset.MinValue; }
|
||||
public bool CanAttempt(DateTimeOffset now) => now >= NextAttempt;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace QVPN.Core;
|
||||
|
||||
public static partial class Subscription
|
||||
{
|
||||
public static Uri ValidateUrl(string input)
|
||||
{
|
||||
if (!Uri.TryCreate(input.Trim(), UriKind.Absolute, out var u) || u.Scheme != "https" || !u.IsDefaultPort || u.UserInfo.Length != 0 || u.Fragment.Length != 0
|
||||
|| !(u.IdnHost.EndsWith(".withblancvpn.online", StringComparison.OrdinalIgnoreCase) || u.IdnHost.Equals("withblancvpn.online", StringComparison.OrdinalIgnoreCase))
|
||||
|| !SubscriptionPath().IsMatch(u.AbsolutePath) || u.Query.Length != 0)
|
||||
throw new ArgumentException("Нужна HTTPS-ссылка подписки BlancVPN вида https://….withblancvpn.online/s/… .");
|
||||
return u;
|
||||
}
|
||||
[GeneratedRegex(@"^/s/[A-Za-z0-9_-]{16,128}/?$")] private static partial Regex SubscriptionPath();
|
||||
|
||||
public static async Task<(List<VpnNode> Nodes, string? Info)> DownloadAsync(string url, CancellationToken token)
|
||||
{
|
||||
ValidateUrl(url);
|
||||
try { return await FetchAsync(url, token); }
|
||||
catch (Exception e) when ((e is HttpRequestException or OperationCanceledException) && !token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, token);
|
||||
return await FetchAsync(url, token);
|
||||
}
|
||||
}
|
||||
private static async Task<(List<VpnNode> Nodes, string? Info)> FetchAsync(string url, CancellationToken token)
|
||||
{
|
||||
var uri = ValidateUrl(url);
|
||||
using var handler = new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false };
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("v2rayN/7.0 QVPN/1.0");
|
||||
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 не найден.");
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
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); }
|
||||
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)
|
||||
{
|
||||
text = text.Trim('\uFEFF', ' ', '\r', '\n', '\t');
|
||||
if (!text.StartsWith("vless://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { text = Encoding.UTF8.GetString(Convert.FromBase64String(text)); }
|
||||
catch (FormatException) { throw new InvalidDataException("BlancVPN вернул неподдерживаемый формат подписки."); }
|
||||
}
|
||||
var nodes = new List<VpnNode>();
|
||||
foreach (string line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!Uri.TryCreate(line, UriKind.Absolute, out var u) || u.Scheme != "vless") throw new InvalidDataException("Подписка содержит неподдерживаемый протокол. Ожидается VLESS/Reality.");
|
||||
var q = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in u.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) { var kv = item.Split('=', 2); q[Uri.UnescapeDataString(kv[0])] = kv.Length == 2 ? Uri.UnescapeDataString(kv[1]) : ""; }
|
||||
string Get(string name, string fallback = "") => q.GetValueOrDefault(name, fallback);
|
||||
if (Get("security") != "reality" || Get("type", "tcp") != "tcp" || Get("headerType", "none") != "none" || Get("encryption", "none") != "none") throw new InvalidDataException("Эта версия QVPN поддерживает ключи BlancVPN VLESS/Reality TCP.");
|
||||
if (!Guid.TryParse(Uri.UnescapeDataString(u.UserInfo), out var uuid) || u.Host.Length == 0 || u.Port is < 1 or > 65535) throw new InvalidDataException("Некорректная локация в подписке.");
|
||||
var host = u.IdnHost.Trim('[', ']');
|
||||
// Subscription data cannot direct a privileged daemon to local/internal services.
|
||||
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];
|
||||
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);
|
||||
if (nodes.All(n => n.Id != id)) nodes.Add(new(id, name, host, u.Port, uuid.ToString(), sni, pub, sid, fp, flow));
|
||||
if (nodes.Count > 256) throw new InvalidDataException("В подписке слишком много локаций.");
|
||||
}
|
||||
if (nodes.Count == 0) throw new InvalidDataException("В подписке нет доступных локаций.");
|
||||
return nodes;
|
||||
}
|
||||
private static bool PublicIp(IPAddress ip)
|
||||
{
|
||||
if (IPAddress.IsLoopback(ip)) return false;
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) return !ip.IsIPv6LinkLocal && !ip.IsIPv6SiteLocal && !ip.IsIPv6Multicast && (ip.GetAddressBytes()[0] & 0xfe) != 0xfc && !ip.Equals(IPAddress.IPv6Any) && (!ip.IsIPv4MappedToIPv6 || PublicIp(ip.MapToIPv4()));
|
||||
var b = ip.GetAddressBytes(); return !(b[0] is 0 or 10 or 127 || b[0] >= 224 || b[0] == 169 && b[1] == 254 || b[0] == 172 && b[1] is >= 16 and <= 31 || b[0] == 192 && b[1] == 168 || b[0] == 100 && b[1] is >= 64 and <= 127 || b[0] == 198 && b[1] is 18 or 19);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using QVPN.Core;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace QVPN.Service;
|
||||
|
||||
public sealed class ControlServer(VpnController controller, string ownerSid)
|
||||
{
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
var security = new PipeSecurity(); security.SetAccessRuleProtection(true, false);
|
||||
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(ownerSid), PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
// Multiple server instances require CreateNewInstance. The service identity gets it;
|
||||
// the interactive user receives only client rights when the daemon runs as SYSTEM.
|
||||
security.AddAccessRule(new PipeAccessRule(WindowsIdentity.GetCurrent().User!, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var listeners = Enumerable.Range(0, 4).Select(_ => ListenAsync(security, lifetime.Token)).ToArray();
|
||||
var finished = await Task.WhenAny(listeners);
|
||||
lifetime.Cancel();
|
||||
try { await Task.WhenAll(listeners); } catch (OperationCanceledException) when (token.IsCancellationRequested) { }
|
||||
await finished;
|
||||
}
|
||||
private async Task ListenAsync(PipeSecurity security, CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
using var server = NamedPipeServerStreamAcl.Create(Constants.PipeName, PipeDirection.InOut, 4, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 8192, 8192, security);
|
||||
await server.WaitForConnectionAsync(token);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); timeout.CancelAfter(TimeSpan.FromSeconds(40));
|
||||
try
|
||||
{
|
||||
string? actual = null;
|
||||
server.RunAsClient(() => actual = WindowsIdentity.GetCurrent().User?.Value);
|
||||
if (actual != ownerSid && actual != "S-1-5-18") continue;
|
||||
var request = await Wire.ReadAsync<Request>(server, timeout.Token);
|
||||
Response response;
|
||||
try { response = await controller.ExecuteAsync(request, timeout.Token); }
|
||||
catch (Exception e) { response = new(false, SafeError.Describe(e), controller.GetStatus()); }
|
||||
await Wire.WriteAsync(server, response, timeout.Token);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or OperationCanceledException or InvalidDataException or UnauthorizedAccessException or System.Text.Json.JsonException or System.Security.SecurityException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QVPN.Service;
|
||||
|
||||
/// <summary>Windows closes all owned engine processes if the service crashes or stops.</summary>
|
||||
sealed class ProcessJob : IDisposable
|
||||
{
|
||||
private IntPtr handle;
|
||||
public ProcessJob()
|
||||
{
|
||||
handle = CreateJobObject(IntPtr.Zero, null);
|
||||
if (handle == IntPtr.Zero) throw new Win32Exception();
|
||||
var info = new ExtendedLimit { Basic = new BasicLimit { LimitFlags = 0x2000 } };
|
||||
if (!SetInformationJobObject(handle, 9, ref info, (uint)Marshal.SizeOf<ExtendedLimit>())) { Dispose(); throw new Win32Exception(); }
|
||||
}
|
||||
public void Add(Process process) { if (!AssignProcessToJobObject(handle, process.Handle)) throw new Win32Exception(); }
|
||||
public void Dispose() { if (handle != IntPtr.Zero) { CloseHandle(handle); handle = IntPtr.Zero; } }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct BasicLimit { public long PerProcessUserTimeLimit, PerJobUserTimeLimit; public uint LimitFlags; public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize; public uint ActiveProcessLimit; public UIntPtr Affinity; public uint PriorityClass, SchedulingClass; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct IoCounters { public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount, ReadTransferCount, WriteTransferCount, OtherTransferCount; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct ExtendedLimit { public BasicLimit Basic; public IoCounters Io; public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed; }
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr CreateJobObject(IntPtr attributes, string? name);
|
||||
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimit info, uint size);
|
||||
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
||||
[DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Win32;
|
||||
using QVPN.Core;
|
||||
using QVPN.Service;
|
||||
using System.Security.Principal;
|
||||
using System.ServiceProcess;
|
||||
|
||||
if (args.Contains("--console"))
|
||||
{
|
||||
string? Arg(string key) { int i = Array.IndexOf(args, key); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; }
|
||||
string path = Arg("--data") ?? Path.Combine(Path.GetTempPath(), "QVPN-dev");
|
||||
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"));
|
||||
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
|
||||
{
|
||||
var running = new[] { controller.RunAsync(stop.Token), pipe.RunAsync(stop.Token) };
|
||||
var finished = await Task.WhenAny(running);
|
||||
stop.Cancel();
|
||||
await finished;
|
||||
await Task.WhenAll(running);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception error) { Console.Error.WriteLine("Diagnostic failure: " + error.GetType().Name + ": " + error.Message); Environment.ExitCode = 1; }
|
||||
}
|
||||
else ServiceBase.Run(new QvpnWindowsService());
|
||||
|
||||
sealed class QvpnWindowsService : ServiceBase
|
||||
{
|
||||
private CancellationTokenSource? stop;
|
||||
private Task? task;
|
||||
private VpnController? controller;
|
||||
public QvpnWindowsService() { ServiceName = Constants.ServiceName; CanStop = true; CanShutdown = true; CanHandlePowerEvent = true; AutoLog = false; }
|
||||
protected override void OnStart(string[] args)
|
||||
{
|
||||
string owner = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\QVPN", "OwnerSid", null) as string ?? throw new InvalidOperationException("QVPN owner is not configured.");
|
||||
_ = new SecurityIdentifier(owner);
|
||||
stop = new();
|
||||
controller = new VpnController(Constants.DataPath, false, null);
|
||||
var pipe = new ControlServer(controller, owner);
|
||||
task = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var running = new[] { controller.RunAsync(stop.Token), pipe.RunAsync(stop.Token) };
|
||||
await Task.WhenAny(running);
|
||||
if (!stop.IsCancellationRequested) Environment.Exit(1);
|
||||
await Task.WhenAll(running);
|
||||
}
|
||||
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
|
||||
catch { Environment.Exit(1); } // SCM recovery restarts the service after unexpected fatal failure.
|
||||
});
|
||||
}
|
||||
protected override void OnStop()
|
||||
{
|
||||
RequestAdditionalTime(15000); stop?.Cancel();
|
||||
try { task?.Wait(TimeSpan.FromSeconds(12)); } catch { }
|
||||
if (controller != null) controller.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
stop?.Dispose();
|
||||
}
|
||||
protected override void OnShutdown() => OnStop();
|
||||
protected override bool OnPowerEvent(PowerBroadcastStatus status)
|
||||
{
|
||||
if (status is PowerBroadcastStatus.ResumeAutomatic or PowerBroadcastStatus.ResumeSuspend or PowerBroadcastStatus.ResumeCritical) controller?.Resume();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><AssemblyName>QVPN.Service</AssemblyName></PropertyGroup><ItemGroup><ProjectReference Include="..\QVPN.Core\QVPN.Core.csproj" /><PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0" /></ItemGroup></Project>
|
||||
@@ -0,0 +1,290 @@
|
||||
using QVPN.Core;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QVPN.Service;
|
||||
|
||||
public sealed class VpnController : IAsyncDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1);
|
||||
private readonly SecureStore store;
|
||||
private readonly string directory, corePath, rulesPath;
|
||||
private readonly bool socksOnly;
|
||||
private Settings settings;
|
||||
private readonly RecoveryPolicy recovery = new();
|
||||
private readonly List<string> events = [];
|
||||
private readonly Dictionary<string, int?> latencies = [];
|
||||
private Process? engine;
|
||||
private ProcessJob? job;
|
||||
private VpnNode? active;
|
||||
private string state = "disconnected", message = "VPN отключён";
|
||||
private DateTimeOffset? connectedSince, startedAt;
|
||||
private DateTimeOffset healthDue, refreshDue = DateTimeOffset.UtcNow.AddHours(6);
|
||||
private int restarts, resumeRequested, measuring;
|
||||
private Status snapshot = new("disconnected", "Служба запускается", false, null, null, null, 0, null, true, true, "", null, [], []);
|
||||
private readonly HashSet<string> failed = [];
|
||||
private Task? measureTask;
|
||||
private readonly CancellationTokenSource measurementStop = new();
|
||||
private bool disposed;
|
||||
private string RuntimePath => Path.Combine(directory, "runtime.json");
|
||||
|
||||
public VpnController(string directory, bool socksOnly, string? overrideCore)
|
||||
{
|
||||
this.directory = directory; this.socksOnly = socksOnly; store = new(directory);
|
||||
corePath = overrideCore ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "core", "sing-box.exe"));
|
||||
rulesPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(corePath)!, "..", "rules"));
|
||||
try { settings = store.Load(); }
|
||||
catch { settings = new(); Event("Не удалось прочитать настройки. Добавьте ключ заново; исходный файл сохранён."); }
|
||||
if (!string.IsNullOrEmpty(settings.SubscriptionUrl))
|
||||
{
|
||||
try { Subscription.ValidateUrl(settings.SubscriptionUrl); }
|
||||
catch { settings = new(); Event("Настройки подписки повреждены. Добавьте ключ заново; исходный файл сохранён."); }
|
||||
}
|
||||
Event("Служба QVPN запущена" + (socksOnly ? " (диагностика SOCKS)" : "")); Publish();
|
||||
}
|
||||
public Status GetStatus() => Volatile.Read(ref snapshot);
|
||||
public void Resume() => Interlocked.Exchange(ref resumeRequested, 1);
|
||||
private void Event(string text)
|
||||
{
|
||||
lock (events)
|
||||
{
|
||||
string line = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss") + " " + text;
|
||||
events.Add(line); if (events.Count > 120) events.RemoveAt(0);
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(directory, "events.log");
|
||||
if (File.Exists(path) && new FileInfo(path).Length > 512 * 1024) File.Move(path, path + ".1", true);
|
||||
File.AppendAllText(path, line + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
private void State(string newState, string text) { if (state != newState || message != text) Event(text); state = newState; message = text; }
|
||||
private void Publish()
|
||||
{
|
||||
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));
|
||||
}
|
||||
public async Task<Response> ExecuteAsync(Request request, CancellationToken token)
|
||||
{
|
||||
if (request.Command == "status") return new(true, null, GetStatus());
|
||||
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;
|
||||
case "disconnect":
|
||||
settings.DesiredConnected = 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("Локация не найдена. Обновите подписку.");
|
||||
Persist(s => s.SelectedId = request.Value ?? ""); failed.Clear(); recovery.Reset(); await StopEngineAsync();
|
||||
if (settings.DesiredConnected) State("connecting", "Подключаемся к выбранной локации…"); break;
|
||||
case "russian":
|
||||
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("Измеряем задержку локаций…");
|
||||
}
|
||||
break;
|
||||
default: throw new ArgumentException("Неизвестная команда QVPN.");
|
||||
}
|
||||
Publish(); return new(true, null, GetStatus());
|
||||
}
|
||||
finally { Publish(); gate.Release(); }
|
||||
}
|
||||
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)
|
||||
{
|
||||
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;
|
||||
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 = "";
|
||||
});
|
||||
refreshDue = DateTimeOffset.UtcNow.AddHours(6);
|
||||
Event("Подписка обновлена: " + result.Nodes.Count + " локаций.");
|
||||
if (old != null && !settings.Nodes.Contains(old)) { await StopEngineAsync(); if (settings.DesiredConnected) State("connecting", "Обновлены параметры локации…"); }
|
||||
}
|
||||
public async Task RunAsync(CancellationToken token)
|
||||
{
|
||||
using var cancelMeasurements = token.Register(() => measurementStop.Cancel());
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await gate.WaitAsync(token);
|
||||
try { await TickAsync(token); }
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
|
||||
catch (Exception e)
|
||||
{
|
||||
await StopEngineAsync(); recovery.Failed(DateTimeOffset.UtcNow); State("recovering", SafeError.Describe(e) + " Повторяем подключение автоматически.");
|
||||
}
|
||||
finally { Publish(); gate.Release(); }
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), token);
|
||||
}
|
||||
}
|
||||
finally { await gate.WaitAsync(); try { await StopEngineAsync(); } finally { gate.Release(); } }
|
||||
}
|
||||
private async Task TickAsync(CancellationToken token)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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)
|
||||
{
|
||||
try { await RefreshAsync(settings.SubscriptionUrl, token); }
|
||||
catch (Exception e) when (e is not OperationCanceledException || !token.IsCancellationRequested) { Event(SafeError.Describe(e)); refreshDue = now.AddMinutes(15); }
|
||||
}
|
||||
if (engine is { HasExited: true })
|
||||
{
|
||||
Event("VPN-процесс завершился. Код: " + engine.ExitCode); await FailedEngineAsync(now);
|
||||
}
|
||||
if (engine == null)
|
||||
{
|
||||
if (!recovery.CanAttempt(now)) return;
|
||||
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; }
|
||||
}
|
||||
if (engine != null && now >= healthDue)
|
||||
{
|
||||
healthDue = now.AddSeconds(15);
|
||||
if (await HealthyAsync(token))
|
||||
{
|
||||
recovery.Healthy(); connectedSince ??= now;
|
||||
if (now - startedAt >= TimeSpan.FromMinutes(2)) { recovery.Stable(); failed.Clear(); }
|
||||
State("connected", socksOnly ? "Диагностика: VPN-прокси работает; системный туннель выключен." : "VPN подключён. Соединение проверено.");
|
||||
}
|
||||
else if (recovery.HealthFailed())
|
||||
{
|
||||
Event("Три проверки соединения не прошли. Восстанавливаем VPN."); await FailedEngineAsync(now);
|
||||
}
|
||||
else State("recovering", "Проверяем соединение… Автовосстановление включено.");
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
private async Task FailedEngineAsync(DateTimeOffset now)
|
||||
{
|
||||
if (active != null && settings.AutoFailover) failed.Add(active.Id);
|
||||
await StopEngineAsync(); restarts++; recovery.Failed(now); 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())
|
||||
{
|
||||
var stderr = check.StandardError.ReadToEndAsync(token); var stdout = check.StandardOutput.ReadToEndAsync(token);
|
||||
using var limit = CancellationTokenSource.CreateLinkedTokenSource(token); limit.CancelAfter(15000);
|
||||
try { await check.WaitForExitAsync(limit.Token); await Task.WhenAll(stderr, stdout); }
|
||||
catch { try { check.Kill(true); } catch { } throw; }
|
||||
if (check.ExitCode != 0) throw new InvalidDataException("VPN-модуль отклонил конфигурацию. Переустановите QVPN.");
|
||||
}
|
||||
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; }
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
job?.Dispose(); job = null;
|
||||
if (process != null)
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); } catch { } finally { process.Dispose(); }
|
||||
}
|
||||
connectedSince = null; startedAt = null; active = 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;
|
||||
}
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var concurrency = new SemaphoreSlim(6);
|
||||
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));
|
||||
}
|
||||
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(); }
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
finally { Interlocked.Exchange(ref measuring, 0); }
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed) return; disposed = true;
|
||||
measurementStop.Cancel();
|
||||
await gate.WaitAsync(); try { await StopEngineAsync(); } finally { gate.Release(); }
|
||||
if (measureTask != null) try { await measureTask.WaitAsync(TimeSpan.FromSeconds(5)); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,352 @@
|
||||
using Microsoft.Win32;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QVPN.Setup;
|
||||
|
||||
internal sealed class Installer(Action<string> progress)
|
||||
{
|
||||
internal static readonly string InstallRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "QVPN");
|
||||
internal static readonly string DataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "QVPN");
|
||||
private static readonly string ServiceExe = Path.Combine(InstallRoot, "service", "QVPN.Service.exe");
|
||||
private const string SettingsKey = @"SOFTWARE\QVPN";
|
||||
private const string UninstallKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\QVPN";
|
||||
private static readonly SecurityIdentifier SystemSid = new(WellKnownSidType.LocalSystemSid, null);
|
||||
private static readonly SecurityIdentifier AdminSid = new(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
private static readonly SecurityIdentifier UsersSid = new(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
public void Install(string? suppliedOwner)
|
||||
{
|
||||
RequireAdministrator();
|
||||
GuardTree(InstallRoot); GuardTree(DataRoot);
|
||||
NativeService.ValidateBinary(ServiceExe);
|
||||
var owner = ResolveOwner(suppliedOwner);
|
||||
bool previousService = NativeService.Exists(), wasRunning = NativeService.IsRunning();
|
||||
bool oldBundle = Directory.Exists(InstallRoot);
|
||||
string suffix = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "-" + Guid.NewGuid().ToString("N")[..8];
|
||||
string stage = InstallRoot + ".stage-" + suffix, backup = InstallRoot + ".backup-" + suffix;
|
||||
bool moved = false, installed = false, stopped = false;
|
||||
try
|
||||
{
|
||||
progress("Распаковка Windows-приложения и VPN-ядра…");
|
||||
CreateProtectedDirectory(stage, false);
|
||||
ExtractPayload(stage);
|
||||
File.Copy(Environment.ProcessPath ?? throw new InvalidOperationException("Не найден установщик."), Path.Combine(stage, "QVPN-Setup.exe"));
|
||||
VerifyBundle(stage);
|
||||
progress("Остановка предыдущей службы…");
|
||||
NativeService.Stop();
|
||||
stopped = true;
|
||||
WaitForInstalledProcesses();
|
||||
GuardTree(InstallRoot); GuardTree(DataRoot);
|
||||
if (oldBundle)
|
||||
{
|
||||
Directory.Move(InstallRoot, backup);
|
||||
moved = true;
|
||||
progress("Резервная копия программы: " + backup);
|
||||
}
|
||||
Directory.Move(stage, InstallRoot);
|
||||
installed = true;
|
||||
ProtectTree(InstallRoot, false);
|
||||
if (!Directory.Exists(DataRoot)) CreateProtectedDirectory(DataRoot, true);
|
||||
ProtectTree(DataRoot, true);
|
||||
using (var key = Registry.LocalMachine.CreateSubKey(SettingsKey, true))
|
||||
{
|
||||
key.SetValue("OwnerSid", owner.Value);
|
||||
key.SetValue("InstallPath", InstallRoot);
|
||||
key.SetValue("DataPath", DataRoot);
|
||||
key.SetValue("Version", "1.0.0");
|
||||
}
|
||||
progress("Настройка автоматического запуска и восстановления службы…");
|
||||
ConfigureService(previousService);
|
||||
NativeService.Start();
|
||||
RegisterUninstall();
|
||||
CreateShortcut();
|
||||
progress("QVPN установлен. Служба работает. Подписку можно добавить в приложении.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
progress("Ошибка установки. Восстановление предыдущего состояния…");
|
||||
try
|
||||
{
|
||||
if (stopped || installed) { NativeService.Stop(); WaitForInstalledProcesses(); }
|
||||
if (installed) RemoveGuardedTree(InstallRoot);
|
||||
if (moved) Directory.Move(backup, InstallRoot);
|
||||
if (!previousService && NativeService.Exists()) RunSc("delete", NativeService.Name);
|
||||
if (!oldBundle)
|
||||
{
|
||||
Registry.LocalMachine.DeleteSubKeyTree(SettingsKey, false);
|
||||
Registry.LocalMachine.DeleteSubKeyTree(UninstallKey, false);
|
||||
}
|
||||
if (previousService && stopped)
|
||||
{
|
||||
if (moved) ConfigureService(true);
|
||||
if (wasRunning) NativeService.Start();
|
||||
}
|
||||
}
|
||||
catch (Exception rollback) { progress("Восстановление требует проверки: " + rollback.Message); }
|
||||
throw;
|
||||
}
|
||||
finally { if (Directory.Exists(stage)) RemoveGuardedTree(stage); }
|
||||
}
|
||||
|
||||
public void Uninstall(bool removeData)
|
||||
{
|
||||
RequireAdministrator();
|
||||
GuardTree(InstallRoot); GuardTree(DataRoot);
|
||||
NativeService.ValidateBinary(ServiceExe);
|
||||
progress("Остановка VPN и Windows-службы…");
|
||||
NativeService.Stop();
|
||||
WaitForInstalledProcesses();
|
||||
if (NativeService.Exists()) RunSc("delete", NativeService.Name);
|
||||
RemoveShortcut();
|
||||
RemoveOwnerAutorun();
|
||||
RemoveGuardedTree(InstallRoot);
|
||||
Registry.LocalMachine.DeleteSubKeyTree(UninstallKey, false);
|
||||
if (removeData)
|
||||
{
|
||||
progress("Удаление сохранённой подписки и настроек…");
|
||||
RemoveGuardedTree(DataRoot);
|
||||
Registry.LocalMachine.DeleteSubKeyTree(SettingsKey, false);
|
||||
}
|
||||
progress(removeData ? "QVPN и сохранённые настройки удалены." : "QVPN удалён. Подписка и настройки сохранены для повторной установки.");
|
||||
}
|
||||
|
||||
private static SecurityIdentifier ResolveOwner(string? supplied)
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(SettingsKey);
|
||||
string? existing = key?.GetValue("OwnerSid") as string;
|
||||
var interactive = NativeService.InteractiveUserSid();
|
||||
var owner = new SecurityIdentifier(supplied ?? existing ?? interactive.Value);
|
||||
NativeService.ValidateUserSid(owner);
|
||||
if (owner.Value != interactive.Value)
|
||||
throw new InvalidOperationException("SID владельца отличается от пользователя текущего Windows-сеанса. Запустите установку из сеанса владельца VPN с параметром --owner и его SID.");
|
||||
if (existing is not null && owner.Value != existing)
|
||||
throw new InvalidOperationException("Эта установка принадлежит другой учётной записи. Её настройки не изменены.");
|
||||
return owner;
|
||||
}
|
||||
|
||||
private static void ExtractPayload(string destination)
|
||||
{
|
||||
using var payload = Assembly.GetExecutingAssembly().GetManifestResourceStream("QVPN.Payload.zip")
|
||||
?? throw new InvalidOperationException("Установщик не содержит программы. Запустите scripts/build.ps1.");
|
||||
using var zip = new ZipArchive(payload, ZipArchiveMode.Read);
|
||||
foreach (var entry in zip.Entries)
|
||||
{
|
||||
string relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
|
||||
string target = Path.GetFullPath(Path.Combine(destination, relative));
|
||||
if (!target.StartsWith(destination + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains(':') || (entry.ExternalAttributes & 0x400) != 0 || ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000)
|
||||
throw new InvalidDataException("Недопустимый путь в пакете: " + entry.FullName);
|
||||
if (entry.FullName.EndsWith('/')) { Directory.CreateDirectory(target); continue; }
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
using var source = entry.Open();
|
||||
using var file = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
source.CopyTo(file);
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyBundle(string root)
|
||||
{
|
||||
string[] required = [@"app\QVPN.exe", @"service\QVPN.Service.exe", @"core\sing-box.exe", @"core\libcronet.dll", @"core\LICENSE"];
|
||||
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)
|
||||
throw new InvalidDataException("В установщике отсутствуют правила маршрутизации.");
|
||||
string manifestPath = Path.Combine(root, "payload-manifest.json");
|
||||
var manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(manifestPath))
|
||||
?? throw new InvalidDataException("Повреждён список файлов установщика.");
|
||||
foreach (var entry in manifest)
|
||||
{
|
||||
string file = Path.GetFullPath(Path.Combine(root, entry.Key));
|
||||
if (!file.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Недопустимый путь в списке файлов.");
|
||||
using var input = File.OpenRead(file);
|
||||
string hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(input));
|
||||
if (!string.Equals(hash, entry.Value, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Повреждён файл установщика: " + entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureService(bool exists)
|
||||
{
|
||||
RunSc(exists ? "config" : "create", NativeService.Name, "binPath=", "\"" + ServiceExe + "\"", "start=", "delayed-auto", "obj=", "LocalSystem", "DisplayName=", "QVPN — Blanc VPN");
|
||||
RunSc("description", NativeService.Name, "QVPN: VPN-туннель Blanc VPN с автоматическим восстановлением подключения.");
|
||||
RunSc("failure", NativeService.Name, "reset=", "86400", "actions=", "restart/5000/restart/10000/restart/30000");
|
||||
RunSc("failureflag", NativeService.Name, "1");
|
||||
}
|
||||
|
||||
private static void RunSc(params string[] arguments)
|
||||
{
|
||||
var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "sc.exe"))
|
||||
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (string argument in arguments) start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("Не удалось запустить диспетчер служб.");
|
||||
var output = process.StandardOutput.ReadToEndAsync();
|
||||
var errors = process.StandardError.ReadToEndAsync();
|
||||
if (!process.WaitForExit(15000)) { process.Kill(true); throw new TimeoutException("Windows не завершила настройку службы."); }
|
||||
Task.WaitAll(output, errors);
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException("Ошибка диспетчера служб " + process.ExitCode + ": " + output.Result + errors.Result);
|
||||
}
|
||||
|
||||
private static void WaitForInstalledProcesses()
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(15);
|
||||
while (true)
|
||||
{
|
||||
bool found = false;
|
||||
foreach (var process in Process.GetProcesses())
|
||||
{
|
||||
using (process)
|
||||
{
|
||||
if (process.Id == Environment.ProcessId) continue;
|
||||
string? path;
|
||||
try { path = process.MainModule?.FileName; } catch { continue; }
|
||||
if (path is null || !path.StartsWith(InstallRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
found = true;
|
||||
if (Path.GetFileName(path).Equals("QVPN.exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { process.CloseMainWindow(); if (!process.WaitForExit(1500)) { process.Kill(true); process.WaitForExit(3000); } }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return;
|
||||
if (DateTime.UtcNow > deadline) throw new IOException("Процессы предыдущей установки ещё работают. Закройте QVPN и повторите установку.");
|
||||
Thread.Sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterUninstall()
|
||||
{
|
||||
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.0");
|
||||
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");
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord); key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||
}
|
||||
|
||||
private static string ShortcutPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), "QVPN.lnk");
|
||||
private static void CreateShortcut()
|
||||
{
|
||||
GuardPath(ShortcutPath);
|
||||
Type shellType = Type.GetTypeFromProgID("WScript.Shell") ?? throw new InvalidOperationException("Недоступны Windows-ярлыки.");
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
|
||||
try
|
||||
{
|
||||
if (File.Exists(ShortcutPath) && !string.Equals((string)shortcut.TargetPath, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase))
|
||||
throw new IOException("Ярлык QVPN в меню Пуск принадлежит другой программе.");
|
||||
shortcut.TargetPath = Path.Combine(InstallRoot, "app", "QVPN.exe");
|
||||
shortcut.WorkingDirectory = Path.Combine(InstallRoot, "app");
|
||||
shortcut.Description = "QVPN — подключение к Blanc VPN";
|
||||
shortcut.Save();
|
||||
}
|
||||
finally { Marshal.FinalReleaseComObject(shortcut); Marshal.FinalReleaseComObject(shell); }
|
||||
}
|
||||
|
||||
private static void RemoveShortcut()
|
||||
{
|
||||
GuardPath(ShortcutPath);
|
||||
if (!File.Exists(ShortcutPath)) return;
|
||||
Type? shellType = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (shellType is null) return;
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
dynamic shortcut = shell.CreateShortcut(ShortcutPath);
|
||||
try { if (string.Equals((string)shortcut.TargetPath, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase)) File.Delete(ShortcutPath); }
|
||||
finally { Marshal.FinalReleaseComObject(shortcut); Marshal.FinalReleaseComObject(shell); }
|
||||
}
|
||||
|
||||
private static void RemoveOwnerAutorun()
|
||||
{
|
||||
using var settings = Registry.LocalMachine.OpenSubKey(SettingsKey);
|
||||
string? owner = settings?.GetValue("OwnerSid") as string;
|
||||
if (owner is null) return;
|
||||
using var run = Registry.Users.OpenSubKey(owner + @"\Software\Microsoft\Windows\CurrentVersion\Run", true);
|
||||
if (run?.GetValue("QVPN") is string command && command.Contains(Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase))
|
||||
run.DeleteValue("QVPN", false);
|
||||
}
|
||||
|
||||
internal static void RequireAdministrator()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
|
||||
throw new UnauthorizedAccessException("Установка службы QVPN требует подтверждения администратора Windows.");
|
||||
}
|
||||
|
||||
internal static void GuardPath(string path)
|
||||
{
|
||||
string? current = Path.GetFullPath(path);
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if ((Directory.Exists(current) || File.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
|
||||
throw new IOException("Установка остановлена: путь содержит ссылку или точку перенаправления: " + current);
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static void GuardTree(string root)
|
||||
{
|
||||
GuardPath(root);
|
||||
if (!Directory.Exists(root)) return;
|
||||
var queue = new Queue<string>(); queue.Enqueue(root);
|
||||
while (queue.TryDequeue(out string? directory))
|
||||
foreach (string entry in Directory.EnumerateFileSystemEntries(directory))
|
||||
{
|
||||
FileAttributes attributes = File.GetAttributes(entry);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0) throw new IOException("В каталоге QVPN обнаружено перенаправление: " + entry);
|
||||
if ((attributes & FileAttributes.Directory) != 0) queue.Enqueue(entry);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ProtectDirectory(string path, bool privateData)
|
||||
{
|
||||
new DirectoryInfo(path).SetAccessControl(DirectoryAcl(privateData));
|
||||
}
|
||||
|
||||
internal static void CreateProtectedDirectory(string path, bool privateData)
|
||||
{
|
||||
GuardPath(path);
|
||||
new DirectoryInfo(path).Create(DirectoryAcl(privateData));
|
||||
}
|
||||
|
||||
private static DirectorySecurity DirectoryAcl(bool privateData)
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(true, false); security.SetOwner(AdminSid);
|
||||
const InheritanceFlags flags = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
security.AddAccessRule(new FileSystemAccessRule(SystemSid, FileSystemRights.FullControl, flags, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(AdminSid, FileSystemRights.FullControl, flags, PropagationFlags.None, AccessControlType.Allow));
|
||||
if (!privateData) security.AddAccessRule(new FileSystemAccessRule(UsersSid, FileSystemRights.ReadAndExecute, flags, PropagationFlags.None, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
private static void ProtectTree(string root, bool privateData)
|
||||
{
|
||||
GuardTree(root);
|
||||
ProtectDirectory(root, privateData);
|
||||
foreach (string directory in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)) ProtectDirectory(directory, privateData);
|
||||
foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var security = new FileSecurity(); security.SetAccessRuleProtection(false, false); security.SetOwner(AdminSid);
|
||||
new FileInfo(file).SetAccessControl(security);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveGuardedTree(string path)
|
||||
{
|
||||
string full = Path.GetFullPath(path);
|
||||
string expectedParent = Path.GetDirectoryName(InstallRoot)!;
|
||||
bool binaryTarget = Path.GetDirectoryName(full)!.Equals(expectedParent, StringComparison.OrdinalIgnoreCase) &&
|
||||
(full.Equals(InstallRoot, StringComparison.OrdinalIgnoreCase) || full.StartsWith(InstallRoot + ".stage-", StringComparison.OrdinalIgnoreCase));
|
||||
if (!binaryTarget && !full.Equals(DataRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Небезопасный путь удаления.");
|
||||
GuardTree(full);
|
||||
if (Directory.Exists(full)) Directory.Delete(full, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
|
||||
namespace QVPN.Setup;
|
||||
|
||||
internal static class NativeService
|
||||
{
|
||||
public const string Name = "QVPNService";
|
||||
private const uint QueryConfig = 1, QueryStatus = 4, StartAccess = 16, StopAccess = 32;
|
||||
|
||||
public static bool Exists()
|
||||
{
|
||||
using var service = Open(QueryStatus, true);
|
||||
return !service.IsInvalid;
|
||||
}
|
||||
|
||||
public static bool IsRunning()
|
||||
{
|
||||
using var service = Open(QueryStatus, true);
|
||||
if (service.IsInvalid) return false;
|
||||
Check(QueryServiceStatus(service, out var status));
|
||||
return status.CurrentState == 4;
|
||||
}
|
||||
|
||||
public static void ValidateBinary(string expected)
|
||||
{
|
||||
using var service = Open(QueryConfig, true);
|
||||
if (service.IsInvalid) return;
|
||||
QueryServiceConfig(service, IntPtr.Zero, 0, out uint required);
|
||||
var pointer = Marshal.AllocHGlobal(checked((int)required));
|
||||
try
|
||||
{
|
||||
Check(QueryServiceConfig(service, pointer, required, out _));
|
||||
var config = Marshal.PtrToStructure<ServiceConfig>(pointer);
|
||||
string actual = Marshal.PtrToStringUni(config.BinaryPathName) ?? "";
|
||||
if (!string.Equals(actual.Trim().Trim('"'), expected, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Служба QVPNService принадлежит другой установке: " + actual);
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(pointer); }
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
using var service = Open(QueryStatus | StopAccess, true);
|
||||
if (service.IsInvalid) return;
|
||||
Check(QueryServiceStatus(service, out var status));
|
||||
if (status.CurrentState == 1) return;
|
||||
if (status.CurrentState != 3 && !ControlService(service, 1, out _))
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
if (error != 1062) throw new Win32Exception(error);
|
||||
}
|
||||
var timeout = DateTime.UtcNow.AddSeconds(60);
|
||||
do
|
||||
{
|
||||
Check(QueryServiceStatus(service, out status));
|
||||
if (status.CurrentState == 1) return;
|
||||
Thread.Sleep(250);
|
||||
} while (DateTime.UtcNow < timeout);
|
||||
throw new TimeoutException("Служба не остановилась за 60 секунд. Файлы установки не изменены.");
|
||||
}
|
||||
|
||||
public static void Start()
|
||||
{
|
||||
using var service = Open(QueryStatus | StartAccess, false);
|
||||
if (!StartService(service, 0, IntPtr.Zero))
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
if (error != 1056) throw new Win32Exception(error);
|
||||
}
|
||||
var timeout = DateTime.UtcNow.AddSeconds(30);
|
||||
do
|
||||
{
|
||||
Check(QueryServiceStatus(service, out var status));
|
||||
if (status.CurrentState == 4) return;
|
||||
if (status.CurrentState == 1) throw new InvalidOperationException("Служба QVPN остановилась при запуске. Код Windows: " + status.Win32ExitCode);
|
||||
Thread.Sleep(250);
|
||||
} while (DateTime.UtcNow < timeout);
|
||||
throw new TimeoutException("Служба QVPN не запустилась за 30 секунд.");
|
||||
}
|
||||
|
||||
public static SecurityIdentifier InteractiveUserSid()
|
||||
{
|
||||
int sessionId = System.Diagnostics.Process.GetCurrentProcess().SessionId;
|
||||
if (sessionId == 0) throw new InvalidOperationException("Установите QVPN из обычного Windows-сеанса пользователя.");
|
||||
string user = GetSessionInfo(sessionId, 5);
|
||||
string domain = GetSessionInfo(sessionId, 7);
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new InvalidOperationException("Не удалось определить пользователя Windows-сеанса.");
|
||||
return (SecurityIdentifier)new NTAccount(domain, user).Translate(typeof(SecurityIdentifier));
|
||||
}
|
||||
|
||||
public static void ValidateUserSid(SecurityIdentifier sid)
|
||||
{
|
||||
uint nameSize = 0, domainSize = 0;
|
||||
byte[] binary = new byte[sid.BinaryLength];
|
||||
sid.GetBinaryForm(binary, 0);
|
||||
LookupAccountSid(null, binary, null, ref nameSize, null, ref domainSize, out _);
|
||||
var name = new StringBuilder(checked((int)nameSize));
|
||||
var domain = new StringBuilder(checked((int)domainSize));
|
||||
Check(LookupAccountSid(null, binary, name, ref nameSize, domain, ref domainSize, out int use));
|
||||
if (use != 1 || !sid.Value.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
throw new ArgumentException("Владелец должен быть учётной записью пользователя Windows.");
|
||||
}
|
||||
|
||||
private static string GetSessionInfo(int session, int info)
|
||||
{
|
||||
Check(WTSQuerySessionInformation(IntPtr.Zero, session, info, out var buffer, out _));
|
||||
try { return Marshal.PtrToStringUni(buffer) ?? ""; }
|
||||
finally { WTSFreeMemory(buffer); }
|
||||
}
|
||||
|
||||
private static ServiceHandle Open(uint access, bool allowMissing)
|
||||
{
|
||||
using var scm = OpenSCManager(null, null, 1);
|
||||
if (scm.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
var service = OpenService(scm, Name, access);
|
||||
if (service.IsInvalid && (!allowMissing || Marshal.GetLastWin32Error() != 1060))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
return service;
|
||||
}
|
||||
|
||||
private static void Check(bool result) { if (!result) throw new Win32Exception(Marshal.GetLastWin32Error()); }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)] private struct ServiceStatus
|
||||
{ public uint ServiceType, CurrentState, ControlsAccepted, Win32ExitCode, ServiceSpecificExitCode, CheckPoint, WaitHint; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct ServiceConfig
|
||||
{ public uint ServiceType, StartType, ErrorControl; public IntPtr BinaryPathName, LoadOrderGroup; public uint TagId; public IntPtr Dependencies, ServiceStartName, DisplayName; }
|
||||
|
||||
private sealed class ServiceHandle : Microsoft.Win32.SafeHandles.SafeHandleZeroOrMinusOneIsInvalid
|
||||
{ public ServiceHandle() : base(true) { } protected override bool ReleaseHandle() => CloseServiceHandle(handle); }
|
||||
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern ServiceHandle OpenSCManager(string? machine, string? database, uint access);
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern ServiceHandle OpenService(ServiceHandle manager, string name, uint access);
|
||||
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool CloseServiceHandle(IntPtr handle);
|
||||
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool QueryServiceStatus(ServiceHandle service, out ServiceStatus status);
|
||||
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool ControlService(ServiceHandle service, uint control, out ServiceStatus status);
|
||||
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool StartService(ServiceHandle service, uint argc, IntPtr argv);
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool QueryServiceConfig(ServiceHandle service, IntPtr config, uint size, out uint needed);
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool LookupAccountSid(string? system, byte[] sid, StringBuilder? name, ref uint nameLength, StringBuilder? domain, ref uint domainLength, out int use);
|
||||
[DllImport("wtsapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool WTSQuerySessionInformation(IntPtr server, int session, int info, out IntPtr buffer, out int bytes);
|
||||
[DllImport("wtsapi32.dll")] private static extern void WTSFreeMemory(IntPtr pointer);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QVPN.Setup;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
string logPath = Path.Combine(Path.GetTempPath(), "QVPN-Setup-" + DateTime.Now.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8] + ".log");
|
||||
using var log = new StreamWriter(new FileStream(logPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { AutoFlush = true };
|
||||
void Write(string message) { lock (log) log.WriteLine(DateTimeOffset.Now.ToString("O") + " " + message); }
|
||||
try
|
||||
{
|
||||
Installer.RequireAdministrator();
|
||||
bool uninstall = args.Contains("--uninstall", StringComparer.OrdinalIgnoreCase) || args.Contains("--uninstall-worker", StringComparer.OrdinalIgnoreCase);
|
||||
bool silent = args.Contains("--silent", StringComparer.OrdinalIgnoreCase);
|
||||
if (args.Contains("--uninstall", StringComparer.OrdinalIgnoreCase) && IsInsideInstall())
|
||||
{
|
||||
BootstrapUninstall(args);
|
||||
return 0;
|
||||
}
|
||||
if (args.Contains("--uninstall-worker", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string? parentArg = Value(args, "--parentpid");
|
||||
if (int.TryParse(parentArg, out int parent))
|
||||
try { using var process = Process.GetProcessById(parent); if (!process.WaitForExit(15000)) throw new TimeoutException("Не завершён предыдущий установщик."); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
Action<Action<string>> operation = progress =>
|
||||
{
|
||||
var installer = new Installer(message => { Write(message); progress(message); });
|
||||
if (uninstall) installer.Uninstall(args.Contains("--remove-data", StringComparer.OrdinalIgnoreCase));
|
||||
else installer.Install(Value(args, "--owner"));
|
||||
};
|
||||
int result;
|
||||
if (silent) { operation(_ => { }); result = 0; }
|
||||
else
|
||||
{
|
||||
using var form = new SetupForm(uninstall, logPath, operation, Write);
|
||||
Application.Run(form); result = form.Result;
|
||||
}
|
||||
if (args.Contains("--uninstall-worker", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
string? executable = Environment.ProcessPath;
|
||||
if (executable is not null) { MoveFileEx(executable, null, 4); MoveFileEx(Path.GetDirectoryName(executable)!, null, 4); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Write(error.ToString());
|
||||
if (!args.Contains("--silent", StringComparer.OrdinalIgnoreCase)) MessageBox.Show(error.Message + "\n\nЖурнал: " + logPath, "QVPN — ошибка установки", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Value(string[] args, string name)
|
||||
{
|
||||
int index = Array.FindIndex(args, arg => arg.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (index < 0) return null;
|
||||
if (index + 1 >= args.Length || args[index + 1].StartsWith("--")) throw new ArgumentException("Не задано значение параметра " + name);
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
private static bool IsInsideInstall() => Environment.ProcessPath?.StartsWith(Installer.InstallRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) == true;
|
||||
private static void BootstrapUninstall(string[] args)
|
||||
{
|
||||
Installer.GuardPath(Installer.InstallRoot);
|
||||
string temp = Path.Combine(Path.GetTempPath(), "QVPN-Uninstall-" + Guid.NewGuid().ToString("N"));
|
||||
Installer.GuardPath(temp);
|
||||
Installer.CreateProtectedDirectory(temp, true);
|
||||
string executable = Path.Combine(temp, "QVPN-Uninstall.exe");
|
||||
File.Copy(Environment.ProcessPath!, executable);
|
||||
var info = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true };
|
||||
info.ArgumentList.Add("--uninstall-worker"); info.ArgumentList.Add("--parentpid"); info.ArgumentList.Add(Environment.ProcessId.ToString());
|
||||
foreach (string arg in args) if (!arg.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)) info.ArgumentList.Add(arg);
|
||||
using var worker = Process.Start(info) ?? throw new InvalidOperationException("Не удалось запустить удаление QVPN.");
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool MoveFileEx(string existing, string? replacement, int flags);
|
||||
}
|
||||
|
||||
internal sealed class SetupForm : Form
|
||||
{
|
||||
public int Result { get; private set; } = 1;
|
||||
private readonly TextBox output = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, BackColor = Color.White, BorderStyle = BorderStyle.FixedSingle };
|
||||
private readonly Button close = new() { Text = "Закрыть", Enabled = false, Dock = DockStyle.Right, Width = 120 };
|
||||
private bool busy = true;
|
||||
|
||||
public SetupForm(bool uninstall, string logPath, Action<Action<string>> operation, Action<string> log)
|
||||
{
|
||||
Text = uninstall ? "Удаление QVPN" : "Установка QVPN — Blanc VPN";
|
||||
Font = new Font("Segoe UI", 10); ClientSize = new Size(660, 370); StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimizeBox = false; MaximizeBox = false; FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
var title = new Label { Text = uninstall ? "Удаление Windows-клиента QVPN" : "Надёжное подключение к Blanc VPN", Height = 44, Dock = DockStyle.Top, Font = new Font(Font, FontStyle.Bold), Padding = new Padding(10) };
|
||||
var footer = new Panel { Height = 52, Dock = DockStyle.Bottom, Padding = new Padding(10) }; footer.Controls.Add(close);
|
||||
var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10, 0, 10, 0) }; panel.Controls.Add(output);
|
||||
Controls.Add(panel); Controls.Add(footer); Controls.Add(title);
|
||||
close.Click += (_, _) => Close();
|
||||
FormClosing += (_, e) => { if (busy) e.Cancel = true; };
|
||||
Shown += async (_, _) =>
|
||||
{
|
||||
void Progress(string message) { if (IsHandleCreated) BeginInvoke(() => output.AppendText(message + Environment.NewLine + Environment.NewLine)); }
|
||||
try { await Task.Run(() => operation(Progress)); Result = 0; }
|
||||
catch (Exception error) { log(error.ToString()); output.AppendText("Ошибка: " + error.Message + "\r\n\r\nЖурнал: " + logPath); Result = 1; }
|
||||
finally { busy = false; close.Enabled = true; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AssemblyName>QVPN-Setup</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="Exists('../../dist/payload.zip')">
|
||||
<EmbeddedResource Include="../../dist/payload.zip" LogicalName="QVPN.Payload.zip" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="QVPN.Setup" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges></security></trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application></compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using QVPN.Core;
|
||||
|
||||
// All fixtures are generated locally. No real subscription, server credential, or key is in this file.
|
||||
return await Runner.MainAsync(args);
|
||||
|
||||
static class Runner
|
||||
{
|
||||
private static int passed;
|
||||
private static int failed;
|
||||
private const int MaxWireSize = 256 * 1024;
|
||||
private static readonly string FakeUuid = new Guid(Enumerable.Range(1, 16).Select(x => (byte)x).ToArray()).ToString();
|
||||
private static readonly string FakePublicKey = Convert.ToBase64String(Enumerable.Range(1, 32).Select(x => (byte)x).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
private static readonly string FakeUrl = "https://test.withblancvpn.online/s/" + new string('a', 32);
|
||||
|
||||
public static async Task<int> MainAsync(string[] args)
|
||||
{
|
||||
if (args.Length > 0) return await CommandAsync(args);
|
||||
await Test("Blanc URL accepted", () => { Check(Subscription.ValidateUrl(FakeUrl).Scheme == "https"); });
|
||||
await Test("Foreign provider and suffix spoof rejected", () =>
|
||||
{
|
||||
foreach (var url in new[] { "https://example.org/s/", "https://withblancvpn.online.example.org/s/", "https://evilwithblancvpn.online/s/" })
|
||||
Throws<ArgumentException>(() => Subscription.ValidateUrl(url + new string('a', 32)));
|
||||
});
|
||||
await Test("Unicode and punycode provider spoof rejected", () =>
|
||||
{
|
||||
var spoof = new Uri("https://withblаncvpn.online/s/" + new string('a', 32)); // Cyrillic a, not the provider.
|
||||
Throws<ArgumentException>(() => Subscription.ValidateUrl(spoof.AbsoluteUri));
|
||||
Throws<ArgumentException>(() => Subscription.ValidateUrl("https://" + spoof.IdnHost + spoof.AbsolutePath));
|
||||
});
|
||||
await Test("Unsafe URL components rejected", () =>
|
||||
{
|
||||
foreach (var url in new[] { FakeUrl.Replace("https:", "http:"), FakeUrl.Replace("https://", "https://name:secret@"), FakeUrl + "?token=x", FakeUrl + "#fragment", FakeUrl.Replace(".online/", ".online:444/"), FakeUrl.Replace("/s/", "/other/"), FakeUrl.Replace("/s/", "/s/%2f"), FakeUrl[..^20] })
|
||||
Throws<ArgumentException>(() => Subscription.ValidateUrl(url));
|
||||
});
|
||||
await Test("Realistic 50-node plain and base64 subscriptions", () =>
|
||||
{
|
||||
var text = string.Join('\n', Enumerable.Range(0, 50).Select(i => Link("8.8.4.4", 10000 + i, "Location " + i)));
|
||||
Check(Subscription.Parse(text).Count == 50);
|
||||
Check(Subscription.Parse(Convert.ToBase64String(Encoding.UTF8.GetBytes(text))).Count == 50);
|
||||
Check(Subscription.Parse("\uFEFF\r\n " + text + "\r\n").Count == 50);
|
||||
});
|
||||
await Test("Duplicate nodes deduplicated; names sanitized", () =>
|
||||
{
|
||||
var first = Link(name: "Safe%0AName");
|
||||
var nodes = Subscription.Parse(first + "\n" + Link(name: "Other name"));
|
||||
Check(nodes.Count == 1 && nodes[0].Name == "SafeName");
|
||||
Check(Subscription.Parse(Link(name: new string('a', 200)))[0].Name.Length == 160);
|
||||
});
|
||||
await Test("Unsupported protocols and malformed Reality rejected", () =>
|
||||
{
|
||||
var valid = Link();
|
||||
foreach (var bad in new[] { "not base64!", valid.Replace("vless://", "vmess://"), valid.Replace("security=reality", "security=tls"), valid.Replace("type=tcp", "type=ws"), valid.Replace("headerType=none", "headerType=http"), valid.Replace("encryption=none", "encryption=aes"), valid.Replace("pbk=" + FakePublicKey, "pbk=short"), valid.Replace("sid=abcd", "sid=abc"), valid.Replace("sni=example.com", "sni=bad%0Ahost"), valid.Replace("fp=chrome", "fp=unknown"), valid.Replace("flow=xtls-rprx-vision", "flow=unknown"), valid.Replace(FakeUuid, "not-a-uuid"), valid.Replace(":443?", ":0?") })
|
||||
Throws<InvalidDataException>(() => Subscription.Parse(bad));
|
||||
Throws<InvalidDataException>(() => Subscription.Parse(""));
|
||||
Throws<InvalidDataException>(() => Subscription.Parse(valid + "\nnot a location"));
|
||||
Throws<InvalidDataException>(() => Subscription.Parse(string.Join('\n', Enumerable.Range(0, 257).Select(i => Link(port: 10000 + i)))));
|
||||
});
|
||||
await Test("Local/private IP server targets rejected", () =>
|
||||
{
|
||||
foreach (var host in new[] { "127.0.0.1", "0.0.0.0", "10.0.0.1", "172.16.0.1", "172.31.0.1", "192.168.1.1", "169.254.1.1", "100.64.0.1", "198.18.0.1", "224.0.0.1", "255.255.255.255", "[::1]", "[::]", "[fc00::1]", "[fd12::1]", "[fe80::1]", "[ff02::1]", "[::ffff:127.0.0.1]", "[::ffff:192.168.1.1]", "localhost", "example.com" })
|
||||
Throws<InvalidDataException>(() => Subscription.Parse(Link(host)));
|
||||
});
|
||||
await Test("DPAPI encrypted atomic store and protected ACL", StoreAsync);
|
||||
await Test("Wire request and status roundtrip", async () =>
|
||||
{
|
||||
var request = new Request("SetRussianDirect", Enabled: false);
|
||||
using var buffer = new MemoryStream();
|
||||
await Wire.WriteAsync(buffer, request, default); buffer.Position = 0;
|
||||
Check(await Wire.ReadAsync<Request>(buffer, default) == request);
|
||||
var status = new Status("Disconnected", "Ready", false, null, null, null, 0, null, true, true, "", null, [], []);
|
||||
buffer.SetLength(0); buffer.Position = 0;
|
||||
await Wire.WriteAsync(buffer, new Response(true, null, status), default); buffer.Position = 0;
|
||||
var read = await Wire.ReadAsync<Response>(buffer, default);
|
||||
Check(read.Ok && read.Status?.State == "Disconnected" && read.Status.Nodes.Count == 0);
|
||||
});
|
||||
await Test("Wire invalid length and truncated payload rejected", async () =>
|
||||
{
|
||||
foreach (var length in new[] { 0, -1, MaxWireSize + 1, int.MaxValue })
|
||||
{
|
||||
var header = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(header, length);
|
||||
using var buffer = new MemoryStream(header);
|
||||
await ThrowsAsync<InvalidDataException>(() => Wire.ReadAsync<Request>(buffer, default));
|
||||
}
|
||||
using (var shortHeader = new MemoryStream(new byte[3])) await ThrowsAsync<EndOfStreamException>(() => Wire.ReadAsync<Request>(shortHeader, default));
|
||||
var bytes = new byte[5]; BinaryPrimitives.WriteInt32LittleEndian(bytes, 20);
|
||||
using (var shortPayload = new MemoryStream(bytes)) await ThrowsAsync<EndOfStreamException>(() => Wire.ReadAsync<Request>(shortPayload, default));
|
||||
using var nullPayload = new MemoryStream();
|
||||
await Wire.WriteAsync(nullPayload, (Request?)null, default); nullPayload.Position = 0;
|
||||
await ThrowsAsync<InvalidDataException>(() => Wire.ReadAsync<Request>(nullPayload, default));
|
||||
var malformedJson = Encoding.UTF8.GetBytes("{not-json}");
|
||||
var malformedFrame = new byte[4 + malformedJson.Length];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(malformedFrame, malformedJson.Length);
|
||||
malformedJson.CopyTo(malformedFrame, 4);
|
||||
using var malformedPayload = new MemoryStream(malformedFrame);
|
||||
await ThrowsAsync<JsonException>(() => Wire.ReadAsync<Request>(malformedPayload, default));
|
||||
});
|
||||
await Test("Wire outbound bound checked before any write", async () =>
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
await ThrowsAsync<InvalidDataException>(() => Wire.WriteAsync(stream, new Request("Import", new string('x', MaxWireSize)), default));
|
||||
Check(stream.Length == 0);
|
||||
});
|
||||
await Test("TUN strict routing, tunneled DNS and proxy health precedence", Config);
|
||||
await Test("Russian direct toggle and local rule-set wiring", () =>
|
||||
{
|
||||
var directory = NewTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(directory, "geoip-ru.srs"), []);
|
||||
File.WriteAllBytes(Path.Combine(directory, "geosite-category-ru.srs"), []);
|
||||
var on = ConfigBuilder.Build(Subscription.Parse(Link())[0], false, true, directory);
|
||||
Check(on["inbounds"]!.AsArray().Count == 1);
|
||||
Check(on["route"]!["rule_set"]!.AsArray().Count == 2);
|
||||
Check(on["route"]!["rules"]!.AsArray().Count(r => r?["outbound"]?.GetValue<string>() == "direct") == 4);
|
||||
var off = ConfigBuilder.Build(Subscription.Parse(Link())[0], true, false, directory);
|
||||
Check(off["route"]!["rule_set"] is null);
|
||||
Check(off["route"]!["rules"]!.AsArray().Count(r => r?["outbound"]?.GetValue<string>() == "direct") == 1);
|
||||
}
|
||||
finally { RemoveTemporaryDirectory(directory); }
|
||||
});
|
||||
await Test("Recovery requires three failures and healthy resets streak", () =>
|
||||
{
|
||||
var p = new RecoveryPolicy();
|
||||
Check(!p.HealthFailed()); Check(!p.HealthFailed()); p.Healthy();
|
||||
Check(!p.HealthFailed()); Check(!p.HealthFailed()); Check(p.HealthFailed());
|
||||
Check(p.ConsecutiveFailures == 3); p.Healthy(); Check(p.ConsecutiveFailures == 0);
|
||||
});
|
||||
await Test("Recovery backoff bounded; one healthy probe does not clear attempts", () =>
|
||||
{
|
||||
var p = new RecoveryPolicy(); var now = DateTimeOffset.UtcNow;
|
||||
foreach (var seconds in new[] { 2, 4, 8, 16, 32, 60, 60, 60, 60, 60, 60 })
|
||||
{
|
||||
p.Failed(now); Check(p.NextAttempt == now.AddSeconds(seconds));
|
||||
Check(!p.CanAttempt(p.NextAttempt.AddTicks(-1))); Check(p.CanAttempt(p.NextAttempt));
|
||||
}
|
||||
Check(p.Attempts == 10); p.Healthy(); Check(p.Attempts == 10);
|
||||
p.Stable(); Check(p.Attempts == 0); p.Reset(); Check(p.CanAttempt(now));
|
||||
});
|
||||
Console.WriteLine($"RESULT passed={passed} failed={failed}");
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> CommandAsync(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
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"))
|
||||
return await DownloadCommandAsync((await File.ReadAllTextAsync(args[2])).Trim(), args.Length == 4);
|
||||
if (args.Length == 2 && args[0] == "--live-parse")
|
||||
{
|
||||
var nodes = Subscription.Parse(await File.ReadAllTextAsync(args[1]));
|
||||
// Counts only: never echo UUIDs, links, hosts or node names from a real subscription.
|
||||
Console.WriteLine($"LIVE_PARSE nodes={nodes.Count}"); return 0;
|
||||
}
|
||||
if (args.Length == 2 && args[0] == "--engine-check")
|
||||
{
|
||||
var directory = NewTemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var node = Subscription.Parse(Link())[0];
|
||||
foreach (var tun in new[] { false, true }) foreach (var russianDirect in new[] { false, true })
|
||||
{
|
||||
var path = Path.Combine(directory, "test-config.json");
|
||||
await File.WriteAllTextAsync(path, ConfigBuilder.Build(node, tun, russianDirect, directory).ToJsonString());
|
||||
var start = new ProcessStartInfo(Path.GetFullPath(args[1])) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
start.ArgumentList.Add("check"); start.ArgumentList.Add("-c"); start.ArgumentList.Add(path);
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException();
|
||||
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
try { await process.WaitForExitAsync(timeout.Token); }
|
||||
catch { if (!process.HasExited) process.Kill(true); throw; }
|
||||
await Task.WhenAll(stdout, stderr);
|
||||
Console.WriteLine($"ENGINE_CHECK tun={tun} russianDirect={russianDirect} exit={process.ExitCode}");
|
||||
if (process.ExitCode != 0) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
finally { RemoveTemporaryDirectory(directory); }
|
||||
}
|
||||
if (args.Length >= 2 && args[0] == "--client")
|
||||
{
|
||||
string? value = null; bool? enabled = null;
|
||||
for (int i = 2; i < args.Length; i += 2)
|
||||
{
|
||||
if (i + 1 >= args.Length) throw new ArgumentException();
|
||||
if (args[i] == "--value-file") value = (await File.ReadAllTextAsync(args[i + 1])).Trim();
|
||||
else if (args[i] == "--value") value = args[i + 1];
|
||||
else if (args[i] == "--enabled") enabled = bool.Parse(args[i + 1]);
|
||||
else throw new ArgumentException();
|
||||
}
|
||||
var response = await PipeClient.SendAsync(new Request(args[1], value, enabled));
|
||||
// Status exposes only public presentation fields. Requests/credentials are never echoed.
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { response.Ok, response.Error, response.Status }, Constants.Json));
|
||||
return response.Ok ? 0 : 2;
|
||||
}
|
||||
Console.WriteLine("Usage: QVPN.Tests [--download --value-file private-file | --live-parse file | --engine-check executable | --client command [--value-file file] [--value public-node-id] [--enabled true/false]]");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception e) { Console.WriteLine("COMMAND_FAILED type=" + e.GetType().Name); return 1; }
|
||||
}
|
||||
|
||||
private static async Task<int> DownloadCommandAsync(string url, bool diagnose)
|
||||
{
|
||||
var uri = Subscription.ValidateUrl(url);
|
||||
var coreOk = false;
|
||||
try
|
||||
{
|
||||
var result = await Subscription.DownloadAsync(url, default);
|
||||
Console.WriteLine($"DOWNLOAD_CORE nodes={result.Nodes.Count}"); coreOk = true;
|
||||
if (!diagnose) return 0;
|
||||
}
|
||||
catch (Exception e) { DiagnosticException("DOWNLOAD_CORE", e); }
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(uri.IdnHost);
|
||||
Console.WriteLine($"DOWNLOAD_DNS ipv4={addresses.Count(a => a.AddressFamily == AddressFamily.InterNetwork)} ipv6={addresses.Count(a => a.AddressFamily == AddressFamily.InterNetworkV6)} systemProxyBypassed={HttpClient.DefaultProxy.IsBypassed(uri)}");
|
||||
}
|
||||
catch (Exception e) { DiagnosticException("DOWNLOAD_DNS", e); }
|
||||
foreach (var mode in new[] { "direct", "system-proxy", "ipv4-direct" })
|
||||
{
|
||||
var errors = SslPolicyErrors.None;
|
||||
var chainFlags = new HashSet<string>();
|
||||
bool Validate(System.Security.Cryptography.X509Certificates.X509Certificate? certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors policy)
|
||||
{
|
||||
errors = policy;
|
||||
if (chain is not null) foreach (var status in chain.ChainStatus) chainFlags.Add(status.Status.ToString());
|
||||
return policy == SslPolicyErrors.None; // Diagnostic callback keeps strict certificate validation.
|
||||
}
|
||||
using var handler = new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false, UseProxy = mode == "system-proxy",
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, c, ch, err) => Validate(c, ch, err) }
|
||||
};
|
||||
if (mode == "ipv4-direct") handler.ConnectCallback = async (context, token) =>
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, token);
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
try { await socket.ConnectAsync(address, context.DnsEndPoint.Port, token); return new NetworkStream(socket, true); }
|
||||
catch { socket.Dispose(); }
|
||||
}
|
||||
throw new SocketException((int)SocketError.HostUnreachable);
|
||||
};
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("v2rayN/7.0 QVPN/1.0");
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||
Console.WriteLine($"DOWNLOAD_TRANSPORT mode={mode} http={(int)response.StatusCode} tls={errors} chain={string.Join(',', chainFlags)}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
DiagnosticException("DOWNLOAD_TRANSPORT mode=" + mode, e);
|
||||
Console.WriteLine($"DOWNLOAD_TLS mode={mode} errors={errors} chain={string.Join(',', chainFlags)}");
|
||||
}
|
||||
}
|
||||
return coreOk ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> MalformedServiceAsync(string evidencePath)
|
||||
{
|
||||
// Authorized diagnostic regression: invalid frames only, no control-state commands.
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40));
|
||||
var before = await PipeClient.SendAsync(new Request("status"), timeout.Token);
|
||||
var samples = new List<object>();
|
||||
var payload = Encoding.UTF8.GetBytes("{not-json}");
|
||||
var frame = new byte[payload.Length + 4]; BinaryPrimitives.WriteInt32LittleEndian(frame, payload.Length); payload.CopyTo(frame, 4);
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
using (var pipe = new NamedPipeClientStream(".", Constants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification))
|
||||
{
|
||||
await pipe.ConnectAsync(2500, timeout.Token);
|
||||
await pipe.WriteAsync(frame, timeout.Token); await pipe.FlushAsync(timeout.Token);
|
||||
var one = new byte[1]; var read = await pipe.ReadAsync(one, timeout.Token);
|
||||
Check(read == 0); // Malformed input is closed without logging or executing a command.
|
||||
}
|
||||
var status = await PipeClient.SendAsync(new Request("status"), timeout.Token);
|
||||
Check(status.Ok && status.Status is not null);
|
||||
samples.Add(new { Iteration = i + 1, StatusReachable = status.Ok, State = status.Status!.State, status.Status.EnginePid });
|
||||
Console.WriteLine($"MALFORMED_SERVICE iteration={i + 1} status={status.Ok} state={status.Status.State}");
|
||||
}
|
||||
var after = await PipeClient.SendAsync(new Request("status"), timeout.Token);
|
||||
Check(after.Ok && before.Status is not null && after.Status is not null);
|
||||
var evidence = new
|
||||
{
|
||||
CapturedUtc = DateTimeOffset.UtcNow, Mode = "existing diagnostic console, SOCKS only; no TUN/stop/restart commands",
|
||||
MalformedFrames = 8, IdentificationClient = true, StatusSurvived = true,
|
||||
Before = new { before.Status!.State, before.Status.EnginePid, before.Status.DesiredConnected, Nodes = before.Status.Nodes.Count },
|
||||
After = new { after.Status!.State, after.Status.EnginePid, after.Status.DesiredConnected, Nodes = after.Status.Nodes.Count },
|
||||
EngineUnchanged = before.Status.EnginePid == after.Status.EnginePid,
|
||||
Samples = samples
|
||||
};
|
||||
await File.WriteAllTextAsync(Path.GetFullPath(evidencePath), JsonSerializer.Serialize(evidence, new JsonSerializerOptions { WriteIndented = true }));
|
||||
Console.WriteLine("MALFORMED_SERVICE passed=8 status-survived=True engine-unchanged=" + evidence.EngineUnchanged);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void DiagnosticException(string label, Exception exception)
|
||||
{
|
||||
for (Exception? e = exception; e is not null; e = e.InnerException)
|
||||
{
|
||||
// Fixed enums and numeric codes only; Message/ToString can contain the private URL.
|
||||
var code = e is HttpRequestException http ? " httpError=" + http.HttpRequestError + " status=" + http.StatusCode : e is SocketException socket ? " socketError=" + socket.SocketErrorCode : "";
|
||||
Console.WriteLine($"{label} type={e.GetType().Name} hresult=0x{e.HResult:X8}{code}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Link(string host = "8.8.8.8", int port = 443, string name = "Test location") =>
|
||||
$"vless://{FakeUuid}@{host}:{port}?security=reality&type=tcp&headerType=none&encryption=none&pbk={FakePublicKey}&sid=abcd&sni=example.com&fp=chrome&flow=xtls-rprx-vision#{name}";
|
||||
|
||||
private static Task StoreAsync()
|
||||
{
|
||||
var directory = NewTemporaryDirectory();
|
||||
var stage = "acl";
|
||||
try
|
||||
{
|
||||
var owner = WindowsIdentity.GetCurrent().User ?? throw new InvalidOperationException();
|
||||
var allowed = new[] { owner, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) };
|
||||
var acl = new DirectoryInfo(directory).GetAccessControl(); acl.SetAccessRuleProtection(true, false);
|
||||
foreach (FileSystemAccessRule inheritedRule in acl.GetAccessRules(true, false, typeof(SecurityIdentifier))) acl.RemoveAccessRuleSpecific(inheritedRule);
|
||||
foreach (var sid in allowed) acl.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
new DirectoryInfo(directory).SetAccessControl(acl);
|
||||
var store = new SecureStore(directory);
|
||||
Check(store.Load().Nodes.Count == 0);
|
||||
var settings = new Settings { SubscriptionUrl = FakeUrl, Nodes = Subscription.Parse(Link()), DesiredConnected = true, RussianDirect = false, SelectedId = "sentinel-test-id" };
|
||||
stage = "first-save";
|
||||
store.Save(settings);
|
||||
stage = "first-load";
|
||||
var read = store.Load(); Check(read.SubscriptionUrl == FakeUrl && read.Nodes.Count == 1 && read.DesiredConnected && !read.RussianDirect);
|
||||
stage = "replace-save";
|
||||
settings.AutoFailover = false; store.Save(settings); Check(!store.Load().AutoFailover);
|
||||
stage = "encrypted-acl-check";
|
||||
Check(File.Exists(Path.Combine(directory, "settings.dpapi.backup")) && !File.Exists(Path.Combine(directory, "settings.dpapi.new")));
|
||||
foreach (var file in Directory.GetFiles(directory))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(file);
|
||||
Check(!Encoding.UTF8.GetString(bytes).Contains(FakeUrl) && !Encoding.UTF8.GetString(bytes).Contains(FakeUuid));
|
||||
foreach (FileSystemAccessRule rule in new FileInfo(file).GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)))
|
||||
if (rule.AccessControlType == AccessControlType.Allow) Check(allowed.Any(sid => sid == (SecurityIdentifier)rule.IdentityReference));
|
||||
}
|
||||
File.WriteAllBytes(Path.Combine(directory, "settings.dpapi"), new byte[32]);
|
||||
stage = "corruption-check";
|
||||
Throws<CryptographicException>(() => store.Load());
|
||||
}
|
||||
catch { Console.WriteLine("STORE_FAILED stage=" + stage); throw; }
|
||||
finally { RemoveTemporaryDirectory(directory); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void Config()
|
||||
{
|
||||
var node = Subscription.Parse(Link())[0];
|
||||
var config = ConfigBuilder.Build(node, true, true, Path.GetTempPath(), 18999);
|
||||
var tun = config["inbounds"]!.AsArray().Single(n => n?["type"]?.GetValue<string>() == "tun")!;
|
||||
Check(tun["auto_route"]!.GetValue<bool>() && tun["strict_route"]!.GetValue<bool>() && tun["dns_mode"]!.GetValue<string>() == "hijack");
|
||||
Check(tun["address"]!.AsArray().Count == 2);
|
||||
var health = config["inbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue<string>() == "health-in")!;
|
||||
Check(health["listen"]!.GetValue<string>() == "127.0.0.1" && health["listen_port"]!.GetValue<int>() == 18999);
|
||||
var dns = config["dns"]!; Check(dns["final"]!.GetValue<string>() == "remote-dns");
|
||||
var server = dns["servers"]!.AsArray().Single()!;
|
||||
Check(server["type"]!.GetValue<string>() == "https" && server["detour"]!.GetValue<string>() == "proxy" && server["tls"]!["enabled"]!.GetValue<bool>());
|
||||
var route = config["route"]!; Check(route["final"]!.GetValue<string>() == "proxy" && route["auto_detect_interface"]!.GetValue<bool>());
|
||||
var rules = route["rules"]!.AsArray();
|
||||
var healthRule = rules.Single(r => r?["inbound"]?.GetValue<string>() == "health-in")!;
|
||||
Check(healthRule["outbound"]!.GetValue<string>() == "proxy");
|
||||
Check(rules.IndexOf(healthRule) < rules.Select((r, i) => (r, i)).Where(x => x.r?["outbound"]?.GetValue<string>() == "direct").Min(x => x.i));
|
||||
Check(rules.Any(r => r?["action"]?.GetValue<string>() == "hijack-dns"));
|
||||
var proxy = config["outbounds"]!.AsArray().Single(n => n?["tag"]?.GetValue<string>() == "proxy")!;
|
||||
Check(proxy["type"]!.GetValue<string>() == "vless" && proxy["tls"]!["reality"]!["enabled"]!.GetValue<bool>());
|
||||
Check(config["log"]!["level"]!.GetValue<string>() == "error");
|
||||
}
|
||||
|
||||
private static string NewTemporaryDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "QVPN.Tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path); return path;
|
||||
}
|
||||
private static void RemoveTemporaryDirectory(string path)
|
||||
{
|
||||
var absolute = Path.GetFullPath(path); var root = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
if (!absolute.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !Path.GetFileName(absolute).StartsWith("QVPN.Tests-", StringComparison.Ordinal)) throw new InvalidOperationException("Unsafe test cleanup target.");
|
||||
Directory.Delete(absolute, true);
|
||||
}
|
||||
private static Task Test(string name, Action action) => Test(name, () => { action(); return Task.CompletedTask; });
|
||||
private static async Task Test(string name, Func<Task> action)
|
||||
{
|
||||
try { await action(); passed++; Console.WriteLine("PASS " + name); }
|
||||
catch (Exception e) { failed++; Console.WriteLine("FAIL " + name + " type=" + e.GetType().Name); }
|
||||
}
|
||||
private static void Check(bool condition) { if (!condition) throw new TestFailure(); }
|
||||
private static void Throws<T>(Action action) where T : Exception
|
||||
{
|
||||
try { action(); } catch (T) { return; } throw new TestFailure();
|
||||
}
|
||||
private static async Task ThrowsAsync<T>(Func<Task> action) where T : Exception
|
||||
{
|
||||
try { await action(); } catch (T) { return; } throw new TestFailure();
|
||||
}
|
||||
private sealed class TestFailure : Exception;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../../src/QVPN.Core/QVPN.Core.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user