Fix QVPN service startup after Windows sign-in

This commit is contained in:
Курнат Андрей
2026-10-05 23:17:58 +03:00
parent 7e21b78b57
commit ef622e6229
11 changed files with 264 additions and 20 deletions
+1 -1
View File
@@ -1 +1 @@
<Project><PropertyGroup><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><LangVersion>latest</LangVersion><Version>1.0.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>
+2 -1
View File
@@ -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. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации.
+8
View File
@@ -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
View File
@@ -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"
+3 -2
View File
@@ -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'}
+99
View File
@@ -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
}
+19 -10
View File
@@ -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
View File
@@ -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>
+84
View File
@@ -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;
}
}
+25 -3
View File
@@ -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");
+18
View File
@@ -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);