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.
151 lines
10 KiB
PowerShell
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
|
|
}
|