Files
QVPN/scripts/verify-startup-installed.ps1

100 lines
6.2 KiB
PowerShell

[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
}