From ef622e62295b5a65f03fd097fd8f048de8cd07d7 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: Mon, 5 Oct 2026 23:17:58 +0300 Subject: [PATCH] Fix QVPN service startup after Windows sign-in --- Directory.Build.props | 2 +- README.md | 3 +- research/analysis.md | 8 +++ scripts/build.ps1 | 6 +- scripts/install-and-verify.ps1 | 5 +- scripts/verify-startup-installed.ps1 | 99 ++++++++++++++++++++++++++++ src/QVPN.App/MainWindow.xaml.cs | 29 +++++--- src/QVPN.Core/QVPN.Core.csproj | 2 +- src/QVPN.Core/ServiceStartup.cs | 84 +++++++++++++++++++++++ src/QVPN.Setup/Installer.cs | 28 +++++++- src/QVPN.Setup/NativeService.cs | 18 +++++ 11 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 scripts/verify-startup-installed.ps1 create mode 100644 src/QVPN.Core/ServiceStartup.cs diff --git a/Directory.Build.props b/Directory.Build.props index 60ae072..323d624 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1 +1 @@ -net10.0-windowsenableenablelatest1.0.0truetrue +net10.0-windowsenableenablelatest1.0.1truetrue diff --git a/README.md b/README.md index 035493e..6414219 100644 --- a/README.md +++ b/README.md @@ -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 в поле ключа и нажмите «Добавить ключ». 3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить». @@ -17,6 +17,7 @@ ## Восстановление соединения - Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна. +- Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа. - Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя. - Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд. - Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации. diff --git a/research/analysis.md b/research/analysis.md index 36eebde..3c5516a 100644 --- a/research/analysis.md +++ b/research/analysis.md @@ -73,3 +73,11 @@ Upstream `LICENSE` для точного тега разрешает sing-box п Старый installed-verification.json содержит ошибочный отказ теста: после завершения ядра проверка сразу прочитала предыдущий снимок connected со старым PID. Исправлено ожидание нового PID одновременно с проверенным состоянием connected. Это ошибка первоначального проверочного скрипта, а не подтвержденный отказ восстановления приложения. В конце проверки 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. Этот сценарий проверяет восстановление после остановки службы; реальную перезагрузку компьютера он не выполняет. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 66afb7a..fff962e 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -6,6 +6,7 @@ $projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $distributionRoot = Join-Path $projectRoot 'dist' $payloadRoot = Join-Path $distributionRoot 'payload' $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) { $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) 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' +$installerName = "QVPN-Setup-$version.exe" +$installer = Join-Path $distributionRoot $installerName 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 +"$installerHash $installerName" | Set-Content -LiteralPath (Join-Path $distributionRoot "QVPN-Setup-$version.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 index 8511324..86a1693 100644 --- a/scripts/install-and-verify.ps1 +++ b/scripts/install-and-verify.ps1 @@ -30,8 +30,9 @@ function Write-Report([bool]$success,[string]$failure='') { 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] + $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 -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'} diff --git a/scripts/verify-startup-installed.ps1 b/scripts/verify-startup-installed.ps1 new file mode 100644 index 0000000..042a875 --- /dev/null +++ b/scripts/verify-startup-installed.ps1 @@ -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 +} diff --git a/src/QVPN.App/MainWindow.xaml.cs b/src/QVPN.App/MainWindow.xaml.cs index b45cac5..cb33e8c 100644 --- a/src/QVPN.App/MainWindow.xaml.cs +++ b/src/QVPN.App/MainWindow.xaml.cs @@ -28,12 +28,14 @@ public partial class MainWindow : Window private readonly bool _preview; private readonly string? _screenshot; private readonly SemaphoreSlim _requestGate = new(1, 1); + private readonly ServiceStartup _serviceStartup = new(); 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 ServiceAvailability? _availability; private bool _applyingStatus, _busy, _serviceAvailable, _exitRequested, _hideNoticeShown, _loaded; private List _rows = []; private const string StartupName = "QVPN"; @@ -99,21 +101,25 @@ public partial class MainWindow : Window { var response = await PipeClient.SendAsync(new Request("status"), _closing.Token); if (response.Status is { } status) ApplyStatus(status); - else SetUnavailable(); + else await SetUnavailableAsync(); } catch (OperationCanceledException) when (_closing.IsCancellationRequested) { } - catch (Exception error) { UiLog.Write(error); SetUnavailable(); } + catch (Exception error) { UiLog.Write(error); await SetUnavailableAsync(); } finally { _requestGate.Release(); } } - private void SetUnavailable() + private async Task SetUnavailableAsync() { + if (_closing.IsCancellationRequested) return; _serviceAvailable = false; - ConnectionTitle.Text = "Служба не запущена"; - ConnectHint.Text = "Для VPN нужна служба QVPN"; - StatusBadge.Text = "Нет службы"; + try { _availability = await _serviceStartup.EnsureAsync(_screenshot is null, _closing.Token); } + catch (OperationCanceledException) when (_closing.IsCancellationRequested) { return; } + if (_closing.IsCancellationRequested) return; + ConnectionTitle.Text = _availability.Title; + ConnectHint.Text = "Проверяем службу автоматически"; + StatusBadge.Text = _availability.Title; StatusDot.Fill = Brush("#FFBE76"); - StatusMessage.Text = "Запустите установщик QVPN-Setup.exe. Он установит службу Windows и необходимые компоненты. Если QVPN уже установлен — откройте диагностику или повторите установку."; + StatusMessage.Text = _availability.Message; GlowRing.Fill = Brush("#292C34"); ConnectButton.Background = Brush("#8099B6"); DurationText.Text = "—"; @@ -125,6 +131,7 @@ public partial class MainWindow : Window { _status = status; _serviceAvailable = true; + _availability = null; _applyingStatus = true; try { @@ -199,7 +206,7 @@ public partial class MainWindow : Window return response.Ok; } 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(); } } @@ -320,9 +327,11 @@ public partial class MainWindow : Window 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($"Связь со службой: {(_serviceAvailable ? "доступна" : "недоступна")}"); + if (_availability is { } availability) + report.AppendLine($"Состояние Windows-службы: {availability.State}; код Windows: {availability.WindowsError?.ToString() ?? "—"}\n{availability.Message}"); if (_status is { } status) { report.AppendLine($"Состояние: {status.State}"); @@ -333,7 +342,7 @@ public partial class MainWindow : Window report.AppendLine("\nСобытия службы:"); 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}"); // Never emit URLs supplied by the service, even if a future event accidentally includes one. return Regex.Replace(report.ToString(), @"https?://[^\s]+", "[ссылка скрыта]", RegexOptions.IgnoreCase); diff --git a/src/QVPN.Core/QVPN.Core.csproj b/src/QVPN.Core/QVPN.Core.csproj index 94efed1..4bcc206 100644 --- a/src/QVPN.Core/QVPN.Core.csproj +++ b/src/QVPN.Core/QVPN.Core.csproj @@ -1 +1 @@ - + diff --git a/src/QVPN.Core/ServiceStartup.cs b/src/QVPN.Core/ServiceStartup.cs new file mode 100644 index 0000000..c5ef61c --- /dev/null +++ b/src/QVPN.Core/ServiceStartup.cs @@ -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 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; + } +} diff --git a/src/QVPN.Setup/Installer.cs b/src/QVPN.Setup/Installer.cs index 946226e..c3ffdd2 100644 --- a/src/QVPN.Setup/Installer.cs +++ b/src/QVPN.Setup/Installer.cs @@ -39,6 +39,7 @@ internal sealed class Installer(Action progress) File.Copy(Environment.ProcessPath ?? throw new InvalidOperationException("Не найден установщик."), Path.Combine(stage, "QVPN-Setup.exe")); VerifyBundle(stage); progress("Остановка предыдущей службы…"); + StopInstalledInterface(); NativeService.Stop(); stopped = true; WaitForInstalledProcesses(); @@ -59,10 +60,11 @@ internal sealed class Installer(Action progress) key.SetValue("OwnerSid", owner.Value); key.SetValue("InstallPath", InstallRoot); key.SetValue("DataPath", DataRoot); - key.SetValue("Version", "1.0.0"); + key.SetValue("Version", "1.0.1"); } progress("Настройка автоматического запуска и восстановления службы…"); ConfigureService(previousService); + NativeService.AllowOwnerToStart(owner); NativeService.Start(); RegisterUninstall(); CreateShortcut(); @@ -100,6 +102,7 @@ internal sealed class Installer(Action progress) GuardTree(InstallRoot); GuardTree(DataRoot); NativeService.ValidateBinary(ServiceExe); progress("Остановка VPN и Windows-службы…"); + StopInstalledInterface(); NativeService.Stop(); WaitForInstalledProcesses(); if (NativeService.Exists()) RunSc("delete", NativeService.Name); @@ -174,7 +177,7 @@ internal sealed class Installer(Action progress) 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("failure", NativeService.Name, "reset=", "86400", "actions=", "restart/5000/restart/10000/restart/30000"); RunSc("failureflag", NativeService.Name, "1"); @@ -221,11 +224,30 @@ internal sealed class Installer(Action 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() { 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("DisplayName", "QVPN — Blanc VPN"); key.SetValue("DisplayVersion", "1.0.1"); key.SetValue("Publisher", "QVPN"); key.SetValue("InstallLocation", InstallRoot); key.SetValue("DisplayIcon", Path.Combine(InstallRoot, "app", "QVPN.exe")); key.SetValue("UninstallString", command); key.SetValue("QuietUninstallString", command + " --silent"); diff --git a/src/QVPN.Setup/NativeService.cs b/src/QVPN.Setup/NativeService.cs index 7a5fa19..f37f3df 100644 --- a/src/QVPN.Setup/NativeService.cs +++ b/src/QVPN.Setup/NativeService.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Runtime.InteropServices; +using System.Security.AccessControl; using System.Security.Principal; using System.Text; @@ -81,6 +82,21 @@ internal static class NativeService 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() { 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 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 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 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);