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);