From 7e21b78b57d14b2ab9ff5ddf069bb2baa11d4389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sun, 4 Oct 2026 15:21:58 +0300 Subject: [PATCH] Implement QVPN Windows client for BlancVPN with resilient VPN service --- .gitignore | 20 + Directory.Build.props | 1 + README.md | 49 ++ research/analysis.md | 75 +++ research/core-release.json | 41 ++ research/rules-sources.json | 12 + scripts/build.ps1 | 89 ++++ scripts/install-and-verify.ps1 | 94 ++++ scripts/prepare-core.ps1 | 20 + scripts/uninstall.ps1 | 16 + scripts/verify-recovery-installed.ps1 | 57 +++ src/QVPN.App/App.xaml | 23 + src/QVPN.App/App.xaml.cs | 116 +++++ src/QVPN.App/MainWindow.xaml | 63 +++ src/QVPN.App/MainWindow.xaml.cs | 443 +++++++++++++++++ src/QVPN.App/QVPN.App.csproj | 12 + src/QVPN.App/app.ico | Bin 0 -> 2686 bytes src/QVPN.App/app.manifest | 6 + src/QVPN.Core/ConfigBuilder.cs | 52 ++ src/QVPN.Core/Models.cs | 67 +++ src/QVPN.Core/PipeClient.cs | 35 ++ src/QVPN.Core/QVPN.Core.csproj | 1 + src/QVPN.Core/RecoveryPolicy.cs | 14 + src/QVPN.Core/Subscription.cs | 85 ++++ src/QVPN.Service/ControlServer.cs | 46 ++ src/QVPN.Service/ProcessJob.cs | 27 ++ src/QVPN.Service/Program.cs | 69 +++ src/QVPN.Service/QVPN.Service.csproj | 1 + src/QVPN.Service/VpnController.cs | 290 +++++++++++ src/QVPN.Setup/COPYING | 674 ++++++++++++++++++++++++++ src/QVPN.Setup/Installer.cs | 352 ++++++++++++++ src/QVPN.Setup/NativeService.cs | 144 ++++++ src/QVPN.Setup/Program.cs | 112 +++++ src/QVPN.Setup/QVPN.Setup.csproj | 15 + src/QVPN.Setup/app.manifest | 10 + tests/QVPN.Tests/Program.cs | 415 ++++++++++++++++ tests/QVPN.Tests/QVPN.Tests.csproj | 4 + tools/rules/geoip-ru.srs | Bin 0 -> 50107 bytes tools/rules/geosite-category-ru.srs | Bin 0 -> 8576 bytes 39 files changed, 3550 insertions(+) create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 README.md create mode 100644 research/analysis.md create mode 100644 research/core-release.json create mode 100644 research/rules-sources.json create mode 100644 scripts/build.ps1 create mode 100644 scripts/install-and-verify.ps1 create mode 100644 scripts/prepare-core.ps1 create mode 100644 scripts/uninstall.ps1 create mode 100644 scripts/verify-recovery-installed.ps1 create mode 100644 src/QVPN.App/App.xaml create mode 100644 src/QVPN.App/App.xaml.cs create mode 100644 src/QVPN.App/MainWindow.xaml create mode 100644 src/QVPN.App/MainWindow.xaml.cs create mode 100644 src/QVPN.App/QVPN.App.csproj create mode 100644 src/QVPN.App/app.ico create mode 100644 src/QVPN.App/app.manifest create mode 100644 src/QVPN.Core/ConfigBuilder.cs create mode 100644 src/QVPN.Core/Models.cs create mode 100644 src/QVPN.Core/PipeClient.cs create mode 100644 src/QVPN.Core/QVPN.Core.csproj create mode 100644 src/QVPN.Core/RecoveryPolicy.cs create mode 100644 src/QVPN.Core/Subscription.cs create mode 100644 src/QVPN.Service/ControlServer.cs create mode 100644 src/QVPN.Service/ProcessJob.cs create mode 100644 src/QVPN.Service/Program.cs create mode 100644 src/QVPN.Service/QVPN.Service.csproj create mode 100644 src/QVPN.Service/VpnController.cs create mode 100644 src/QVPN.Setup/COPYING create mode 100644 src/QVPN.Setup/Installer.cs create mode 100644 src/QVPN.Setup/NativeService.cs create mode 100644 src/QVPN.Setup/Program.cs create mode 100644 src/QVPN.Setup/QVPN.Setup.csproj create mode 100644 src/QVPN.Setup/app.manifest create mode 100644 tests/QVPN.Tests/Program.cs create mode 100644 tests/QVPN.Tests/QVPN.Tests.csproj create mode 100644 tools/rules/geoip-ru.srs create mode 100644 tools/rules/geosite-category-ru.srs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55cf68a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..60ae072 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1 @@ +net10.0-windowsenableenablelatest1.0.0truetrue diff --git a/README.md b/README.md new file mode 100644 index 0000000..035493e --- /dev/null +++ b/README.md @@ -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 и закрытый код мобильного приложения в поставку не включены. diff --git a/research/analysis.md b/research/analysis.md new file mode 100644 index 0000000..36eebde --- /dev/null +++ b/research/analysis.md @@ -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 `. Для интеграционной проверки службы предусмотрено `QVPN.Tests --client --value-file `; запрос и значение ключа не выводятся, ответ содержит только публичный Status. Отчёт не является доказательством многочасовой работы VPN. + +`QVPN.Tests --engine-check ` успешно проверил JSON официальным бинарником 1.14.2 для четырёх сочетаний TUN on/off и российского обхода on/off, все `exit=0`. Проверка использует только синтетический ключ и не подключается к серверу. Она подтверждает допустимость конфигурации для выбранного ядра, отдельно от сетевой работы. + +После единичной ошибки загрузки в .NET проведено повторное обследование командой `QVPN.Tests --download --value-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, не демонстрационный режим. Многосуточная эксплуатация и реальный цикл сна/пробуждения этой короткой проверкой не подтверждаются. diff --git a/research/core-release.json b/research/core-release.json new file mode 100644 index 0000000..fe58d7f --- /dev/null +++ b/research/core-release.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/research/rules-sources.json b/research/rules-sources.json new file mode 100644 index 0000000..9a13410 --- /dev/null +++ b/research/rules-sources.json @@ -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" + } +] \ No newline at end of file diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..66afb7a --- /dev/null +++ b/scripts/build.ps1 @@ -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" diff --git a/scripts/install-and-verify.ps1 b/scripts/install-and-verify.ps1 new file mode 100644 index 0000000..8511324 --- /dev/null +++ b/scripts/install-and-verify.ps1 @@ -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 +} diff --git a/scripts/prepare-core.ps1 b/scripts/prepare-core.ps1 new file mode 100644 index 0000000..1745ee0 --- /dev/null +++ b/scripts/prepare-core.ps1 @@ -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.' diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..77cebed --- /dev/null +++ b/scripts/uninstall.ps1 @@ -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.' diff --git a/scripts/verify-recovery-installed.ps1 b/scripts/verify-recovery-installed.ps1 new file mode 100644 index 0000000..6f47d86 --- /dev/null +++ b/scripts/verify-recovery-installed.ps1 @@ -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 +} diff --git a/src/QVPN.App/App.xaml b/src/QVPN.App/App.xaml new file mode 100644 index 0000000..e44cbfc --- /dev/null +++ b/src/QVPN.App/App.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/QVPN.App/App.xaml.cs b/src/QVPN.App/App.xaml.cs new file mode 100644 index 0000000..1fa1e2f --- /dev/null +++ b/src/QVPN.App/App.xaml.cs @@ -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 { } + } +} diff --git a/src/QVPN.App/MainWindow.xaml b/src/QVPN.App/MainWindow.xaml new file mode 100644 index 0000000..0d16cba --- /dev/null +++ b/src/QVPN.App/MainWindow.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +