Fix QVPN service startup after Windows sign-in
This commit is contained in:
@@ -1 +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>
|
<Project><PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><LangVersion>latest</LangVersion><Version>1.0.1</Version><EnableWindowsTargeting>true</EnableWindowsTargeting><Deterministic>true</Deterministic></PropertyGroup></Project>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Установка и работа
|
## Установка и работа
|
||||||
|
|
||||||
1. Запустите `QVPN-Setup-1.0.0.exe`. Windows попросит права администратора один раз для установки VPN-службы.
|
1. Запустите `QVPN-Setup-1.0.1.exe`. Windows попросит права администратора один раз для установки VPN-службы.
|
||||||
2. Откройте ярлык QVPN. Вставьте ссылку BlancVPN в поле ключа и нажмите «Добавить ключ».
|
2. Откройте ярлык QVPN. Вставьте ссылку BlancVPN в поле ключа и нажмите «Добавить ключ».
|
||||||
3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить».
|
3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить».
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
## Восстановление соединения
|
## Восстановление соединения
|
||||||
|
|
||||||
- Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна.
|
- Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна.
|
||||||
|
- Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа.
|
||||||
- Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя.
|
- Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя.
|
||||||
- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд.
|
- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд.
|
||||||
- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации.
|
- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации.
|
||||||
|
|||||||
@@ -73,3 +73,11 @@ Upstream `LICENSE` для точного тега разрешает sing-box п
|
|||||||
Старый installed-verification.json содержит ошибочный отказ теста: после завершения ядра проверка сразу прочитала предыдущий снимок connected со старым PID. Исправлено ожидание нового PID одновременно с проверенным состоянием connected. Это ошибка первоначального проверочного скрипта, а не подтвержденный отказ восстановления приложения.
|
Старый installed-verification.json содержит ошибочный отказ теста: после завершения ядра проверка сразу прочитала предыдущий снимок connected со старым PID. Исправлено ожидание нового PID одновременно с проверенным состоянием connected. Это ошибка первоначального проверочного скрипта, а не подтвержденный отказ восстановления приложения.
|
||||||
|
|
||||||
В конце проверки VPN оставлен включённым, локация Цюрих, Швейцария. Отрисовка установленного интерфейса по реальному статусу — installed-live.png, не демонстрационный режим. Многосуточная эксплуатация и реальный цикл сна/пробуждения этой короткой проверкой не подтверждаются.
|
В конце проверки VPN оставлен включённым, локация Цюрих, Швейцария. Отрисовка установленного интерфейса по реальному статусу — installed-live.png, не демонстрационный режим. Многосуточная эксплуатация и реальный цикл сна/пробуждения этой короткой проверкой не подтверждаются.
|
||||||
|
|
||||||
|
## Ожидание службы после загрузки Windows, 5 октября 2026
|
||||||
|
|
||||||
|
Пользователь сообщил, что после перезагрузки окно предложило переустановить приложение. У установленной службы обнаружен `AUTO_START (DELAYED)`. Загрузка Windows началась в 20:36:56; журнал интерфейса содержит таймауты подключения к каналу с 20:37:27 до 20:39:08. В 20:39 появилась резервная копия повторной установки, а первое событие запущенной службы имеет время 20:39:24. На момент диагностики служба уже работает, ключ сохранён, статус подключения доступен. Эти данные подтверждают период недоступности канала после входа в Windows; автоматический запуск прежней версии без повторной установки в этом цикле отдельно не наблюдался.
|
||||||
|
|
||||||
|
Версия 1.0.1 убирает отложенный запуск службы. При отсутствии ответа интерфейс проверяет состояние в диспетчере служб и автоматически запускает остановленную службу. Установщик добавляет владельцу права START и QUERY_STATUS с сохранением остальных ACL; изменения конфигурации службы и остановка этим разрешением не предоставляются. Сообщение об установке появляется только при подтверждённом отсутствии регистрации службы (код Windows 1060). Ожидание запуска, отключённая служба и отказ доступа показываются отдельно. Обновление сначала закрывает старое окно, затем останавливает службу, чтобы интерфейс не запускал её во время замены файлов.
|
||||||
|
|
||||||
|
Сборка 1.0.1 и 16 групп тестов прошли. `scripts/verify-startup-installed.ps1 -InstallUpdate` обновляет программу с сохранением подписки, проверяет отсутствие отложенного старта, останавливает службу и запускает обычное окно без повышенных прав. Успешный результат требует повторного подключения с прежним числом локаций и HTTPS 204 через системный TUN. JSON и отрисовки проверки исключены из Git. Этот сценарий проверяет восстановление после остановки службы; реальную перезагрузку компьютера он не выполняет.
|
||||||
|
|||||||
+4
-2
@@ -6,6 +6,7 @@ $projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
|||||||
$distributionRoot = Join-Path $projectRoot 'dist'
|
$distributionRoot = Join-Path $projectRoot 'dist'
|
||||||
$payloadRoot = Join-Path $distributionRoot 'payload'
|
$payloadRoot = Join-Path $distributionRoot 'payload'
|
||||||
$setupPublishRoot = Join-Path $distributionRoot 'setup-publish'
|
$setupPublishRoot = Join-Path $distributionRoot 'setup-publish'
|
||||||
|
$version = ([xml](Get-Content -LiteralPath (Join-Path $projectRoot 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
|
||||||
|
|
||||||
function Reset-BuildDirectory([string]$Path) {
|
function Reset-BuildDirectory([string]$Path) {
|
||||||
$absoluteTarget = [IO.Path]::GetFullPath($Path)
|
$absoluteTarget = [IO.Path]::GetFullPath($Path)
|
||||||
@@ -81,9 +82,10 @@ Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|||||||
[IO.Compression.ZipFile]::CreateFromDirectory($payloadRoot, $payloadZip, [IO.Compression.CompressionLevel]::Optimal, $false)
|
[IO.Compression.ZipFile]::CreateFromDirectory($payloadRoot, $payloadZip, [IO.Compression.CompressionLevel]::Optimal, $false)
|
||||||
Write-Host 'Embedding the complete payload in the offline Windows installer...'
|
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)
|
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'
|
$installerName = "QVPN-Setup-$version.exe"
|
||||||
|
$installer = Join-Path $distributionRoot $installerName
|
||||||
Copy-Item -LiteralPath (Join-Path $setupPublishRoot 'QVPN-Setup.exe') -Destination $installer -Force
|
Copy-Item -LiteralPath (Join-Path $setupPublishRoot 'QVPN-Setup.exe') -Destination $installer -Force
|
||||||
$installerHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash
|
$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
|
"$installerHash $installerName" | Set-Content -LiteralPath (Join-Path $distributionRoot "QVPN-Setup-$version.sha256") -Encoding ascii
|
||||||
Write-Host "Installer: $installer"
|
Write-Host "Installer: $installer"
|
||||||
Write-Host "SHA256: $installerHash"
|
Write-Host "SHA256: $installerHash"
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ function Write-Report([bool]$success,[string]$failure='') {
|
|||||||
try {
|
try {
|
||||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
if(![Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator token is required'}
|
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'
|
$version=([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
|
||||||
$expected=(Get-Content (Join-Path $repo 'dist/QVPN-Setup-1.0.0.sha256') -Raw).Split(' ')[0]
|
$setup=Join-Path $repo "dist/QVPN-Setup-$version.exe"
|
||||||
|
$expected=(Get-Content (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0]
|
||||||
if((Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash -ne $expected){throw 'Installer hash mismatch'}
|
if((Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash -ne $expected){throw 'Installer hash mismatch'}
|
||||||
$install=Start-Process -FilePath $setup -ArgumentList @('--silent','--owner',$OwnerSid) -PassThru
|
$install=Start-Process -FilePath $setup -ArgumentList @('--silent','--owner',$OwnerSid) -PassThru
|
||||||
if(!$install.WaitForExit(60000)){throw 'Installer still running'}
|
if(!$install.WaitForExit(60000)){throw 'Installer still running'}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param([switch]$Prepare, [switch]$InstallUpdate)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||||
|
$app = Join-Path $env:ProgramFiles 'QVPN/app/QVPN.exe'
|
||||||
|
$report = Join-Path $repo 'research/installed-startup-verification.json'
|
||||||
|
$preparedReport = Join-Path $repo 'research/startup-prepared-verification.json'
|
||||||
|
$testDll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$administrator = ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
|
||||||
|
function Read-Status {
|
||||||
|
$raw = & dotnet $testDll --client status
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'The service status pipe is unavailable' }
|
||||||
|
$response = $raw | ConvertFrom-Json
|
||||||
|
if (!$response.Ok -or !$response.Status) { throw 'The service status request failed' }
|
||||||
|
return $response.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Prepare) {
|
||||||
|
if (!$administrator) { throw 'Preparation requires an administrator token' }
|
||||||
|
try {
|
||||||
|
$before = Read-Status
|
||||||
|
if (!$before.DesiredConnected -or $before.Nodes.Count -eq 0) { throw 'This test requires an existing connected subscription' }
|
||||||
|
if ($InstallUpdate) {
|
||||||
|
$version = ([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
|
||||||
|
$setup = Join-Path $repo "dist/QVPN-Setup-$version.exe"
|
||||||
|
$expected = (Get-Content (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0]
|
||||||
|
if ((Get-FileHash -LiteralPath $setup).Hash -ne $expected) { throw 'Installer checksum mismatch' }
|
||||||
|
$installer = Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait
|
||||||
|
if ($installer.ExitCode -ne 0) { throw "Update failed: $($installer.ExitCode)" }
|
||||||
|
}
|
||||||
|
$configuration = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\QVPNService'
|
||||||
|
if ($configuration.Start -ne 2 -or $configuration.DelayedAutoStart -eq 1) { throw 'The service still uses delayed or non-automatic startup' }
|
||||||
|
foreach ($process in (Get-Process -Name QVPN -ErrorAction SilentlyContinue)) {
|
||||||
|
if ($process.Path -eq $app) { Stop-Process -Id $process.Id -Force }
|
||||||
|
}
|
||||||
|
$service = Get-Service QVPNService
|
||||||
|
Stop-Service -Name QVPNService
|
||||||
|
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
|
||||||
|
[pscustomobject]@{Prepared=$true;Nodes=$before.Nodes.Count;DesiredConnected=$before.DesiredConnected;DelayedAutoStart=$false;At=[DateTimeOffset]::Now.ToString('o')} |
|
||||||
|
ConvertTo-Json | Set-Content -LiteralPath $preparedReport -Encoding utf8
|
||||||
|
} catch {
|
||||||
|
Start-Service QVPNService -ErrorAction SilentlyContinue
|
||||||
|
[pscustomobject]@{Prepared=$false;Failure=$_.Exception.Message} | ConvertTo-Json | Set-Content -LiteralPath $preparedReport -Encoding utf8
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($administrator) { throw 'Run verification from a regular Windows user token; only the preparation helper is elevated' }
|
||||||
|
try {
|
||||||
|
$arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-Prepare')
|
||||||
|
if ($InstallUpdate) { $arguments += '-InstallUpdate' }
|
||||||
|
$helper = Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -Verb RunAs -WindowStyle Hidden -ArgumentList $arguments -PassThru
|
||||||
|
$helper.WaitForExit()
|
||||||
|
if ($helper.ExitCode -ne 0) { throw 'Elevated preparation did not complete' }
|
||||||
|
$prepared = Get-Content -LiteralPath $preparedReport -Raw | ConvertFrom-Json
|
||||||
|
if (!$prepared.Prepared) { throw 'Service stop/update preparation failed' }
|
||||||
|
|
||||||
|
# Capture is read-only and does not trigger service startup.
|
||||||
|
$capture = Start-Process -FilePath $app -ArgumentList '--capture-current', (Join-Path $repo 'research/startup-waiting.png') -PassThru -Wait
|
||||||
|
if ($capture.ExitCode -ne 1 -or (Get-Service QVPNService).Status -ne 'Stopped') { throw 'Read-only capture unexpectedly started the service' }
|
||||||
|
|
||||||
|
$timer = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
Start-Process -FilePath $app -ArgumentList '--tray' | Out-Null
|
||||||
|
$status = $null
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
try { $status = Read-Status } catch { $status = $null }
|
||||||
|
if ($status -and $status.State -eq 'connected' -and $status.DesiredConnected -and $status.EnginePid) { break }
|
||||||
|
} while ($timer.Elapsed.TotalSeconds -lt 60)
|
||||||
|
if (!$status -or $status.State -ne 'connected') { throw 'The regular user interface did not automatically start/reconnect the stopped service' }
|
||||||
|
if ($status.Nodes.Count -ne $prepared.Nodes) { throw 'The cached subscription was not preserved' }
|
||||||
|
$recoverySeconds = [Math]::Round($timer.Elapsed.TotalSeconds,1)
|
||||||
|
$http = $null
|
||||||
|
foreach ($endpoint in @('https://cp.cloudflare.com/generate_204','https://www.gstatic.com/generate_204')) {
|
||||||
|
$code = & curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' $endpoint
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $code -eq '204') { $http = $code; break }
|
||||||
|
}
|
||||||
|
if ($http -ne '204') { throw 'System HTTPS through the restored TUN failed on both health endpoints' }
|
||||||
|
$capture = Start-Process -FilePath $app -ArgumentList '--capture-current', (Join-Path $repo 'research/startup-connected.png') -PassThru -Wait
|
||||||
|
if ($capture.ExitCode -ne 0) { throw 'The connected UI capture failed' }
|
||||||
|
[pscustomobject]@{
|
||||||
|
Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Version=(Get-Item $app).VersionInfo.FileVersion
|
||||||
|
DelayedAutoStart=$false;ClientElevated=$administrator;RegularUserStartedService=$true
|
||||||
|
Seconds=$recoverySeconds;State=$status.State;Nodes=$status.Nodes.Count
|
||||||
|
KeyEnteredAgain=$false;HttpCode=$http;FullWindowsRebootTested=$false
|
||||||
|
} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8
|
||||||
|
Get-Content -LiteralPath $report
|
||||||
|
} catch {
|
||||||
|
# Restore startup when a verification step fails; this never imports/replaces the key.
|
||||||
|
Start-Process -FilePath $app -ArgumentList '--tray' -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
[pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.Message} |
|
||||||
|
ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8
|
||||||
|
Get-Content -LiteralPath $report
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -28,12 +28,14 @@ public partial class MainWindow : Window
|
|||||||
private readonly bool _preview;
|
private readonly bool _preview;
|
||||||
private readonly string? _screenshot;
|
private readonly string? _screenshot;
|
||||||
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
||||||
|
private readonly ServiceStartup _serviceStartup = new();
|
||||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) };
|
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(2) };
|
||||||
private readonly CancellationTokenSource _closing = new();
|
private readonly CancellationTokenSource _closing = new();
|
||||||
private readonly Forms.NotifyIcon? _tray;
|
private readonly Forms.NotifyIcon? _tray;
|
||||||
private readonly Drawing.Icon? _trayIcon;
|
private readonly Drawing.Icon? _trayIcon;
|
||||||
private Forms.ToolStripMenuItem? _trayDisconnect;
|
private Forms.ToolStripMenuItem? _trayDisconnect;
|
||||||
private Status? _status;
|
private Status? _status;
|
||||||
|
private ServiceAvailability? _availability;
|
||||||
private bool _applyingStatus, _busy, _serviceAvailable, _exitRequested, _hideNoticeShown, _loaded;
|
private bool _applyingStatus, _busy, _serviceAvailable, _exitRequested, _hideNoticeShown, _loaded;
|
||||||
private List<ServerRow> _rows = [];
|
private List<ServerRow> _rows = [];
|
||||||
private const string StartupName = "QVPN";
|
private const string StartupName = "QVPN";
|
||||||
@@ -99,21 +101,25 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
var response = await PipeClient.SendAsync(new Request("status"), _closing.Token);
|
var response = await PipeClient.SendAsync(new Request("status"), _closing.Token);
|
||||||
if (response.Status is { } status) ApplyStatus(status);
|
if (response.Status is { } status) ApplyStatus(status);
|
||||||
else SetUnavailable();
|
else await SetUnavailableAsync();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { }
|
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { }
|
||||||
catch (Exception error) { UiLog.Write(error); SetUnavailable(); }
|
catch (Exception error) { UiLog.Write(error); await SetUnavailableAsync(); }
|
||||||
finally { _requestGate.Release(); }
|
finally { _requestGate.Release(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetUnavailable()
|
private async Task SetUnavailableAsync()
|
||||||
{
|
{
|
||||||
|
if (_closing.IsCancellationRequested) return;
|
||||||
_serviceAvailable = false;
|
_serviceAvailable = false;
|
||||||
ConnectionTitle.Text = "Служба не запущена";
|
try { _availability = await _serviceStartup.EnsureAsync(_screenshot is null, _closing.Token); }
|
||||||
ConnectHint.Text = "Для VPN нужна служба QVPN";
|
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { return; }
|
||||||
StatusBadge.Text = "Нет службы";
|
if (_closing.IsCancellationRequested) return;
|
||||||
|
ConnectionTitle.Text = _availability.Title;
|
||||||
|
ConnectHint.Text = "Проверяем службу автоматически";
|
||||||
|
StatusBadge.Text = _availability.Title;
|
||||||
StatusDot.Fill = Brush("#FFBE76");
|
StatusDot.Fill = Brush("#FFBE76");
|
||||||
StatusMessage.Text = "Запустите установщик QVPN-Setup.exe. Он установит службу Windows и необходимые компоненты. Если QVPN уже установлен — откройте диагностику или повторите установку.";
|
StatusMessage.Text = _availability.Message;
|
||||||
GlowRing.Fill = Brush("#292C34");
|
GlowRing.Fill = Brush("#292C34");
|
||||||
ConnectButton.Background = Brush("#8099B6");
|
ConnectButton.Background = Brush("#8099B6");
|
||||||
DurationText.Text = "—";
|
DurationText.Text = "—";
|
||||||
@@ -125,6 +131,7 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
_status = status;
|
_status = status;
|
||||||
_serviceAvailable = true;
|
_serviceAvailable = true;
|
||||||
|
_availability = null;
|
||||||
_applyingStatus = true;
|
_applyingStatus = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -199,7 +206,7 @@ public partial class MainWindow : Window
|
|||||||
return response.Ok;
|
return response.Ok;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { return false; }
|
catch (OperationCanceledException) when (_closing.IsCancellationRequested) { return false; }
|
||||||
catch (Exception error) { UiLog.Write(error); SetUnavailable(); ShowError("Нет ответа от службы QVPN. Проверьте установку службы. Подключение, если оно уже работает, управляется службой независимо от окна."); return false; }
|
catch (Exception error) { UiLog.Write(error); await SetUnavailableAsync(); ShowError("Служба пока не ответила. Связь с ней восстановится автоматически. Повторите действие после появления статуса подключения."); return false; }
|
||||||
finally { _requestGate.Release(); _busy = false; SetControlsEnabled(); }
|
finally { _requestGate.Release(); _busy = false; SetControlsEnabled(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,9 +327,11 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private string DiagnosticsReport()
|
private string DiagnosticsReport()
|
||||||
{
|
{
|
||||||
var report = new StringBuilder("QVPN 1.0 · диагностика\n");
|
var report = new StringBuilder("QVPN 1.0.1 · диагностика\n");
|
||||||
report.AppendLine($"Время: {DateTimeOffset.Now:O}");
|
report.AppendLine($"Время: {DateTimeOffset.Now:O}");
|
||||||
report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}");
|
report.AppendLine($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}");
|
||||||
|
if (_availability is { } availability)
|
||||||
|
report.AppendLine($"Состояние Windows-службы: {availability.State}; код Windows: {availability.WindowsError?.ToString() ?? "—"}\n{availability.Message}");
|
||||||
if (_status is { } status)
|
if (_status is { } status)
|
||||||
{
|
{
|
||||||
report.AppendLine($"Состояние: {status.State}");
|
report.AppendLine($"Состояние: {status.State}");
|
||||||
@@ -333,7 +342,7 @@ public partial class MainWindow : Window
|
|||||||
report.AppendLine("\nСобытия службы:");
|
report.AppendLine("\nСобытия службы:");
|
||||||
foreach (var entry in status.Events) report.AppendLine(entry);
|
foreach (var entry in status.Events) report.AppendLine(entry);
|
||||||
}
|
}
|
||||||
else report.AppendLine("Запустите установщик QVPN-Setup.exe для установки службы Windows.");
|
else report.AppendLine(_availability?.Message ?? "Ожидаем первое состояние службы Windows.");
|
||||||
report.AppendLine($"\nЖурнал интерфейса: {UiLog.DirectoryPath}");
|
report.AppendLine($"\nЖурнал интерфейса: {UiLog.DirectoryPath}");
|
||||||
// Never emit URLs supplied by the service, even if a future event accidentally includes one.
|
// Never emit URLs supplied by the service, even if a future event accidentally includes one.
|
||||||
return Regex.Replace(report.ToString(), @"https?://[^\s]+", "[ссылка скрыта]", RegexOptions.IgnoreCase);
|
return Regex.Replace(report.ToString(), @"https?://[^\s]+", "[ссылка скрыта]", RegexOptions.IgnoreCase);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" /></ItemGroup></Project>
|
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" /><PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0" /></ItemGroup></Project>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.ServiceProcess;
|
||||||
|
|
||||||
|
namespace QVPN.Core;
|
||||||
|
|
||||||
|
public enum ServiceAvailabilityState { Missing, Starting, Running, Stopped, Stopping, Disabled, AccessDenied, Unavailable }
|
||||||
|
|
||||||
|
public sealed record ServiceAvailability(ServiceAvailabilityState State, int? WindowsError = null)
|
||||||
|
{
|
||||||
|
public string Title => State switch
|
||||||
|
{
|
||||||
|
ServiceAvailabilityState.Missing => "Служба не установлена",
|
||||||
|
ServiceAvailabilityState.Disabled => "Служба отключена",
|
||||||
|
ServiceAvailabilityState.AccessDenied => "Нет доступа к службе",
|
||||||
|
ServiceAvailabilityState.Unavailable => "Ожидание службы",
|
||||||
|
_ => "Запуск службы"
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Message => State switch
|
||||||
|
{
|
||||||
|
ServiceAvailabilityState.Missing => "Служба QVPN отсутствует в Windows. Запустите установщик QVPN-Setup.exe один раз для её установки.",
|
||||||
|
ServiceAvailabilityState.Disabled => "Запуск службы QVPN отключён в Windows. Включите автоматический запуск QVPNService в управлении службами.",
|
||||||
|
ServiceAvailabilityState.AccessDenied => "Windows не разрешила доступ к службе QVPN. Откройте диагностику и проверьте учётную запись владельца установки.",
|
||||||
|
ServiceAvailabilityState.Stopped => "Служба QVPN остановлена. Приложение автоматически повторит запуск. Сохранённый ключ останется на месте.",
|
||||||
|
ServiceAvailabilityState.Stopping => "Windows завершает работу службы QVPN. После остановки приложение автоматически запустит её снова.",
|
||||||
|
ServiceAvailabilityState.Running => "Служба QVPN запущена. Ожидаем ответ; подключение и сохранённый ключ обслуживаются автоматически.",
|
||||||
|
ServiceAvailabilityState.Starting => "Windows запускает службу QVPN. Приложение подключится к ней автоматически; повторный ввод ключа не нужен.",
|
||||||
|
_ => "Ожидаем ответ службы QVPN. Приложение повторит проверку автоматически. Подробности доступны в диагностике."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Querying SCM distinguishes a missing installation from the normal boot/startup window.
|
||||||
|
// The installer grants the owner only START and QUERY_STATUS, never service configuration access.
|
||||||
|
public sealed class ServiceStartup
|
||||||
|
{
|
||||||
|
private long _lastStart;
|
||||||
|
|
||||||
|
public Task<ServiceAvailability> EnsureAsync(bool allowStart, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.Run(() => Ensure(allowStart), cancellationToken);
|
||||||
|
|
||||||
|
private ServiceAvailability Ensure(bool allowStart)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var service = new ServiceController(Constants.ServiceName);
|
||||||
|
var state = service.Status;
|
||||||
|
if (state == ServiceControllerStatus.Stopped && allowStart &&
|
||||||
|
(_lastStart == 0 || Stopwatch.GetElapsedTime(_lastStart) >= TimeSpan.FromSeconds(15)))
|
||||||
|
{
|
||||||
|
_lastStart = Stopwatch.GetTimestamp();
|
||||||
|
try { service.Start(); }
|
||||||
|
catch (InvalidOperationException error) when (WindowsError(error) == 1056) { } // SCM already started it.
|
||||||
|
service.Refresh();
|
||||||
|
state = service.Status;
|
||||||
|
}
|
||||||
|
return new(state switch
|
||||||
|
{
|
||||||
|
ServiceControllerStatus.Running => ServiceAvailabilityState.Running,
|
||||||
|
ServiceControllerStatus.StartPending or ServiceControllerStatus.ContinuePending => ServiceAvailabilityState.Starting,
|
||||||
|
ServiceControllerStatus.StopPending => ServiceAvailabilityState.Stopping,
|
||||||
|
_ => ServiceAvailabilityState.Stopped
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is InvalidOperationException or Win32Exception)
|
||||||
|
{
|
||||||
|
int? code = WindowsError(error);
|
||||||
|
return new(code switch
|
||||||
|
{
|
||||||
|
1060 => ServiceAvailabilityState.Missing,
|
||||||
|
1058 => ServiceAvailabilityState.Disabled,
|
||||||
|
5 => ServiceAvailabilityState.AccessDenied,
|
||||||
|
_ => ServiceAvailabilityState.Unavailable
|
||||||
|
}, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? WindowsError(Exception error)
|
||||||
|
{
|
||||||
|
for (Exception? current = error; current is not null; current = current.InnerException)
|
||||||
|
if (current is Win32Exception windows) return windows.NativeErrorCode;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ internal sealed class Installer(Action<string> progress)
|
|||||||
File.Copy(Environment.ProcessPath ?? throw new InvalidOperationException("Не найден установщик."), Path.Combine(stage, "QVPN-Setup.exe"));
|
File.Copy(Environment.ProcessPath ?? throw new InvalidOperationException("Не найден установщик."), Path.Combine(stage, "QVPN-Setup.exe"));
|
||||||
VerifyBundle(stage);
|
VerifyBundle(stage);
|
||||||
progress("Остановка предыдущей службы…");
|
progress("Остановка предыдущей службы…");
|
||||||
|
StopInstalledInterface();
|
||||||
NativeService.Stop();
|
NativeService.Stop();
|
||||||
stopped = true;
|
stopped = true;
|
||||||
WaitForInstalledProcesses();
|
WaitForInstalledProcesses();
|
||||||
@@ -59,10 +60,11 @@ internal sealed class Installer(Action<string> progress)
|
|||||||
key.SetValue("OwnerSid", owner.Value);
|
key.SetValue("OwnerSid", owner.Value);
|
||||||
key.SetValue("InstallPath", InstallRoot);
|
key.SetValue("InstallPath", InstallRoot);
|
||||||
key.SetValue("DataPath", DataRoot);
|
key.SetValue("DataPath", DataRoot);
|
||||||
key.SetValue("Version", "1.0.0");
|
key.SetValue("Version", "1.0.1");
|
||||||
}
|
}
|
||||||
progress("Настройка автоматического запуска и восстановления службы…");
|
progress("Настройка автоматического запуска и восстановления службы…");
|
||||||
ConfigureService(previousService);
|
ConfigureService(previousService);
|
||||||
|
NativeService.AllowOwnerToStart(owner);
|
||||||
NativeService.Start();
|
NativeService.Start();
|
||||||
RegisterUninstall();
|
RegisterUninstall();
|
||||||
CreateShortcut();
|
CreateShortcut();
|
||||||
@@ -100,6 +102,7 @@ internal sealed class Installer(Action<string> progress)
|
|||||||
GuardTree(InstallRoot); GuardTree(DataRoot);
|
GuardTree(InstallRoot); GuardTree(DataRoot);
|
||||||
NativeService.ValidateBinary(ServiceExe);
|
NativeService.ValidateBinary(ServiceExe);
|
||||||
progress("Остановка VPN и Windows-службы…");
|
progress("Остановка VPN и Windows-службы…");
|
||||||
|
StopInstalledInterface();
|
||||||
NativeService.Stop();
|
NativeService.Stop();
|
||||||
WaitForInstalledProcesses();
|
WaitForInstalledProcesses();
|
||||||
if (NativeService.Exists()) RunSc("delete", NativeService.Name);
|
if (NativeService.Exists()) RunSc("delete", NativeService.Name);
|
||||||
@@ -174,7 +177,7 @@ internal sealed class Installer(Action<string> progress)
|
|||||||
|
|
||||||
private static void ConfigureService(bool exists)
|
private static void ConfigureService(bool exists)
|
||||||
{
|
{
|
||||||
RunSc(exists ? "config" : "create", NativeService.Name, "binPath=", "\"" + ServiceExe + "\"", "start=", "delayed-auto", "obj=", "LocalSystem", "DisplayName=", "QVPN — Blanc VPN");
|
RunSc(exists ? "config" : "create", NativeService.Name, "binPath=", "\"" + ServiceExe + "\"", "start=", "auto", "obj=", "LocalSystem", "DisplayName=", "QVPN — Blanc VPN");
|
||||||
RunSc("description", NativeService.Name, "QVPN: VPN-туннель Blanc VPN с автоматическим восстановлением подключения.");
|
RunSc("description", NativeService.Name, "QVPN: VPN-туннель Blanc VPN с автоматическим восстановлением подключения.");
|
||||||
RunSc("failure", NativeService.Name, "reset=", "86400", "actions=", "restart/5000/restart/10000/restart/30000");
|
RunSc("failure", NativeService.Name, "reset=", "86400", "actions=", "restart/5000/restart/10000/restart/30000");
|
||||||
RunSc("failureflag", NativeService.Name, "1");
|
RunSc("failureflag", NativeService.Name, "1");
|
||||||
@@ -221,11 +224,30 @@ internal sealed class Installer(Action<string> progress)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void StopInstalledInterface()
|
||||||
|
{
|
||||||
|
foreach (var process in Process.GetProcessesByName("QVPN"))
|
||||||
|
{
|
||||||
|
using (process)
|
||||||
|
{
|
||||||
|
string? path;
|
||||||
|
try { path = process.MainModule?.FileName; } catch { continue; }
|
||||||
|
if (!string.Equals(path, Path.Combine(InstallRoot, "app", "QVPN.exe"), StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process.CloseMainWindow();
|
||||||
|
if (!process.WaitForExit(1500)) { process.Kill(true); process.WaitForExit(3000); }
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RegisterUninstall()
|
private static void RegisterUninstall()
|
||||||
{
|
{
|
||||||
using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true);
|
using var key = Registry.LocalMachine.CreateSubKey(UninstallKey, true);
|
||||||
string command = "\"" + Path.Combine(InstallRoot, "QVPN-Setup.exe") + "\" --uninstall";
|
string command = "\"" + Path.Combine(InstallRoot, "QVPN-Setup.exe") + "\" --uninstall";
|
||||||
key.SetValue("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", "1.0.0");
|
key.SetValue("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", "1.0.1");
|
||||||
key.SetValue("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot);
|
key.SetValue("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot);
|
||||||
key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe"));
|
key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe"));
|
||||||
key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent");
|
key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.AccessControl;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -81,6 +82,21 @@ internal static class NativeService
|
|||||||
throw new TimeoutException("Служба QVPN не запустилась за 30 секунд.");
|
throw new TimeoutException("Служба QVPN не запустилась за 30 секунд.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void AllowOwnerToStart(SecurityIdentifier owner)
|
||||||
|
{
|
||||||
|
const uint readControl = 0x20000, writeDacl = 0x40000, daclInformation = 4;
|
||||||
|
using var service = Open(readControl | writeDacl, false);
|
||||||
|
QueryServiceObjectSecurity(service, daclInformation, null, 0, out uint required);
|
||||||
|
var bytes = new byte[required];
|
||||||
|
Check(QueryServiceObjectSecurity(service, daclInformation, bytes, required, out _));
|
||||||
|
var descriptor = new CommonSecurityDescriptor(false, false, bytes, 0);
|
||||||
|
if (descriptor.DiscretionaryAcl is null) throw new InvalidOperationException("У службы QVPN отсутствует список прав доступа.");
|
||||||
|
descriptor.DiscretionaryAcl.AddAccess(AccessControlType.Allow, owner, checked((int)(QueryStatus | StartAccess)), InheritanceFlags.None, PropagationFlags.None);
|
||||||
|
bytes = new byte[descriptor.BinaryLength];
|
||||||
|
descriptor.GetBinaryForm(bytes, 0);
|
||||||
|
Check(SetServiceObjectSecurity(service, daclInformation, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
public static SecurityIdentifier InteractiveUserSid()
|
public static SecurityIdentifier InteractiveUserSid()
|
||||||
{
|
{
|
||||||
int sessionId = System.Diagnostics.Process.GetCurrentProcess().SessionId;
|
int sessionId = System.Diagnostics.Process.GetCurrentProcess().SessionId;
|
||||||
@@ -137,6 +153,8 @@ internal static class NativeService
|
|||||||
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool QueryServiceStatus(ServiceHandle service, out ServiceStatus status);
|
[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 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", SetLastError = true)] private static extern bool StartService(ServiceHandle service, uint argc, IntPtr argv);
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool QueryServiceObjectSecurity(ServiceHandle service, uint information, byte[]? descriptor, uint size, out uint needed);
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true)] private static extern bool SetServiceObjectSecurity(ServiceHandle service, uint information, byte[] descriptor);
|
||||||
[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 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("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", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool WTSQuerySessionInformation(IntPtr server, int session, int info, out IntPtr buffer, out int bytes);
|
||||||
|
|||||||
Reference in New Issue
Block a user