Files
QVPN/scripts/verify-stability-installed.ps1
Курнат Андрей aae6dd28dd Release QVPN 1.0.5 with Xray and resilient tunnel recovery
Integrate Xray 26.9.9 with the sing-box system tunnel and verify Windows routes during health checks.

Improve endpoint selection, network recovery, subscription refresh deadlines, diagnostics, packaging, and regression coverage.
2026-10-30 06:15:08 +03:00

151 lines
10 KiB
PowerShell

[CmdletBinding()]
param([switch]$InstallUpdate, [int]$ObserveSeconds = 300)
$ErrorActionPreference = 'Stop'
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
$root = Join-Path $env:ProgramFiles 'QVPN'
$report = Join-Path $repo 'research/installed-stability-verification.json'
$checks = [Collections.Generic.List[object]]::new()
$observationMismatch = $null
function Send([string]$command, [string]$value = '') {
$arguments = @($dll, '--client', $command)
if ($value) { $arguments += @('--value', $value) }
$response = & dotnet @arguments | ConvertFrom-Json
if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw "Service command failed: $command" }
return $response.Status
}
function Wait-Connected([int]$oldEngine = 0, [int]$oldService = 0) {
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
try {
$status = Send 'status'
$service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
if ($status.State -eq 'connected' -and $status.DesiredConnected -and $status.EnginePid -and
(!$oldEngine -or $status.EnginePid -ne $oldEngine) -and (!$oldService -or $service.ProcessId -ne $oldService)) {
$ownedEngine = Get-CimInstance Win32_Process -Filter "ProcessId=$($status.EnginePid)"
if ($ownedEngine -and $ownedEngine.ParentProcessId -eq $service.ProcessId) { return $status }
}
} catch { }
Start-Sleep -Milliseconds 500
} while ($timer.Elapsed.TotalSeconds -lt 90)
throw 'A verified connection was not restored within 90 seconds'
}
function Verify-SystemHttps {
foreach ($url in @('https://cp.cloudflare.com/generate_204', 'https://www.google.com/generate_204', 'https://www.gstatic.com/generate_204')) {
$code = & curl.exe --silent --noproxy '*' --max-time 8 --output NUL --write-out '%{http_code}' $url
if ($LASTEXITCODE -eq 0 -and $code -eq '204') { return }
}
throw 'System HTTPS through the TUN failed'
}
function Assert-InstalledProcess([int]$processId, [string]$relativePath) {
$process = Get-Process -Id $processId
if ($process.Path -ne (Join-Path $root $relativePath)) { throw 'Unexpected process executable' }
}
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
if (!([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator token is required' }
if ($ObserveSeconds -lt 30 -or $ObserveSeconds -gt 3600) { throw 'Observation must be between 30 and 3600 seconds' }
$before = Send 'status'
if (!$before.DesiredConnected -or !$before.Nodes.Count) { throw 'An existing active subscription is required' }
$version = ([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
if ($InstallUpdate) {
$data = Join-Path $env:ProgramData 'QVPN'
$encryptedSettings = Join-Path $data 'settings.dpapi'
Copy-Item -LiteralPath $encryptedSettings -Destination (Join-Path $data ("settings.dpapi.before-$version-" + [DateTime]::UtcNow.ToString('yyyyMMddHHmmss')))
$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 hash mismatch' }
$installer = Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait
if ($installer.ExitCode -ne 0) { throw 'Installer failed' }
}
$timer = [Diagnostics.Stopwatch]::StartNew()
$status = Wait-Connected
if ($status.VpnCore -ne 'Xray' -or !$status.XrayPid) { throw 'Xray transport is not active' }
Assert-InstalledProcess $status.XrayPid 'core/xray.exe'
if ($status.Nodes.Count -ne $before.Nodes.Count -or $status.SelectedId -ne $before.SelectedId) { throw 'Subscription or selected location changed during update' }
$service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
if ($service.State -ne 'Running' -or $service.StartMode -ne 'Auto' -or $service.StartName -ne 'LocalSystem') { throw 'Service configuration mismatch' }
$adapter = Get-NetAdapter -Name QVPN
if ($adapter.Status -ne 'Up') { throw 'System TUN is not up' }
if (!$status.SystemRoutesVerified) { throw 'System TUN does not own the control routes' }
Verify-SystemHttps
$checks.Add([pscustomobject]@{Scenario='Installed update retains subscription and system TUN';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Nodes=$status.Nodes.Count;Version=(Get-Item (Join-Path $root 'service/QVPN.Service.exe')).VersionInfo.FileVersion})
$engine = $status.EnginePid; $active = $status.ActiveId
$null = Send 'connect'
$null = Send 'select' $status.SelectedId
$status = Send 'status'
if ($status.EnginePid -ne $engine) { throw 'Repeated connection/selection command restarted the engine' }
$checks.Add([pscustomobject]@{Scenario='Repeated commands preserve working tunnel';Passed=$true})
$timer.Restart(); $null = Send 'measure'
do {
$status = Send 'status'
if ($status.Nodes | Where-Object {$null -ne $_.LatencyMs}) { break }
Start-Sleep -Milliseconds 500
} while ($timer.Elapsed.TotalSeconds -lt 20)
if (!($status.Nodes | Where-Object {$null -ne $_.LatencyMs})) { throw 'Direct latency measurements did not complete' }
if ($status.EnginePid -ne $engine -or $status.ActiveId -ne $active) { throw 'Measurement interrupted the working tunnel' }
$checks.Add([pscustomobject]@{Scenario='Direct server measurements preserve working tunnel';Passed=$true;Reachable=@($status.Nodes | Where-Object {$null -ne $_.LatencyMs}).Count})
$selectedName = ($status.Nodes | Where-Object {$_.Id -eq $status.SelectedId} | Select-Object -First 1).Name
$activeName = $status.ActiveName
$timer.Restart(); $null = Send 'refresh'; $status = Wait-Connected
$newSelectedName = ($status.Nodes | Where-Object {$_.Id -eq $status.SelectedId} | Select-Object -First 1).Name
if ($selectedName -and $newSelectedName -ne $selectedName) { throw 'Subscription rotation lost the selected location' }
if ($status.ActiveName -ne $activeName) { throw 'Subscription rotation lost the working location' }
$checks.Add([pscustomobject]@{Scenario='Subscription refresh/credential rotation preserves selected location';Passed=$true;ParametersRotated=($status.EnginePid -ne $engine);Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)})
$engine = $status.EnginePid; $active = $status.ActiveId
$oldXray = $status.XrayPid; Assert-InstalledProcess $oldXray 'core/xray.exe'
$timer.Restart(); Stop-Process -Id $oldXray -Force
$status = Wait-Connected $engine
if ($status.XrayPid -eq $oldXray -or $status.ActiveId -ne $active) { throw 'Xray crash was not restored on the same server' }
Verify-SystemHttps
$checks.Add([pscustomobject]@{Scenario='Xray transport crash reconnects to the same working server';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)})
$engine = $status.EnginePid
Assert-InstalledProcess $engine 'core/sing-box.exe'
$timer.Restart(); Stop-Process -Id $engine -Force
$status = Wait-Connected $engine
if ($status.ActiveId -ne $active) { throw 'Local engine crash unnecessarily changed the working server' }
Verify-SystemHttps
$checks.Add([pscustomobject]@{Scenario='Engine crash reconnects to the same working server';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)})
$engine = $status.EnginePid; $oldXray = $status.XrayPid
$service = Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
Assert-InstalledProcess $service.ProcessId 'service/QVPN.Service.exe'
$oldService = $service.ProcessId; $timer.Restart(); Stop-Process -Id $oldService -Force
Start-Sleep -Milliseconds 1500
if (Get-Process -Id $engine -ErrorAction SilentlyContinue) { throw 'Orphan VPN engine survived service crash' }
if (Get-Process -Id $oldXray -ErrorAction SilentlyContinue) { throw 'Orphan Xray transport survived service crash' }
$status = Wait-Connected $engine $oldService
if ($status.ActiveId -ne $active) { throw 'Service restart lost the last working server' }
Verify-SystemHttps
$checks.Add([pscustomobject]@{Scenario='Service crash restores persisted working server without orphan engine';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)})
$engine = $status.EnginePid; $restarts = $status.Restarts
$timer.Restart(); $samples = 0; $httpsChecks = 0
do {
$status = Send 'status'; $samples++
if ($status.EnginePid -ne $engine -or $status.ActiveId -ne $active -or $status.Restarts -ne $restarts -or $status.State -ne 'connected' -or !$status.SystemRoutesVerified) {
$observationMismatch = [pscustomobject]@{
Expected=[pscustomobject]@{EnginePid=$engine;ActiveId=$active;Restarts=$restarts;State='connected'}
Observed=($status | Select-Object EnginePid,XrayPid,ActiveId,Restarts,State,SystemRoutesVerified)
}
throw 'Tunnel changed or lost checked connection during observation'
}
if ($samples % 6 -eq 1) { Verify-SystemHttps; $httpsChecks++ }
Start-Sleep -Seconds 5
} while ($timer.Elapsed.TotalSeconds -lt $ObserveSeconds)
$checks.Add([pscustomobject]@{Scenario='Continuous installed TUN observation';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Samples=$samples;SystemHttpsChecks=$httpsChecks;UnplannedRestarts=0;ServerChanges=0})
[pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Checks=$checks.ToArray();SleepWakeTested=$false} |
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $report -Encoding utf8
} catch {
[pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.Message;Checks=$checks.ToArray();ObservationMismatch=$observationMismatch} |
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $report -Encoding utf8
exit 1
}