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 в поле ключа и нажмите «Добавить ключ».
|
||||
3. Отключите HAPP и другие VPN, выберите локацию или автоматический выбор, нажмите «Подключить».
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
## Восстановление соединения
|
||||
|
||||
- Служба работает отдельно от интерфейса, от имени LocalSystem; запуск VPN не зависит от открытого окна.
|
||||
- Служба запускается вместе с Windows без отложенного старта. Если она ещё не запущена, окно QVPN запускает её от обычного пользователя и ожидает ответа. Временная задержка после входа в Windows не требует переустановки или повторного ввода ключа.
|
||||
- Ключ и локации зашифрованы Windows DPAPI. Каталог `%ProgramData%\QVPN` доступен только SYSTEM и администраторам; управление интерфейсом идёт через канал с правами конкретного пользователя.
|
||||
- Желание оставаться подключённым сохраняется на диск атомарно. Сбой процесса запускает повторное соединение с паузой от 2 до 60 секунд.
|
||||
- Реальная HTTPS-проверка идёт через VPN. Зелёный статус появляется после успешной проверки. Три неудачные проверки подряд приводят к восстановлению и, по настройке, смене локации.
|
||||
|
||||
@@ -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. Этот сценарий проверяет восстановление после остановки службы; реальную перезагрузку компьютера он не выполняет.
|
||||
|
||||
+4
-2
@@ -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"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 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<ServerRow> _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);
|
||||
|
||||
@@ -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"));
|
||||
VerifyBundle(stage);
|
||||
progress("Остановка предыдущей службы…");
|
||||
StopInstalledInterface();
|
||||
NativeService.Stop();
|
||||
stopped = true;
|
||||
WaitForInstalledProcesses();
|
||||
@@ -59,10 +60,11 @@ internal sealed class Installer(Action<string> 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<string> 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<string> 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<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()
|
||||
{
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user