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