Files
QVPN/scripts/verify-system-routes-installed.ps1
T
Курнат Андрей 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

94 lines
5.5 KiB
PowerShell

[CmdletBinding()]
param([int]$ObserveSeconds = 60)
$ErrorActionPreference = 'Stop'
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
$reportPath = Join-Path $repo 'research/system-route-verification.json'
$report = [ordered]@{Completed=$false;Phase='preflight';CapturedAt=[DateTimeOffset]::Now.ToString('o')}
$injected = $null
function Save([string]$phase) {
$report.Phase=$phase
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding utf8
}
function Status {
$response = & dotnet $dll --client status | ConvertFrom-Json
if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw 'QVPN status is unavailable' }
return $response.Status
}
function Remove-InjectedRoute {
if ($null -eq $script:injected) { return }
# Only this freshly created ActiveStore /32 is owned by the verifier.
if ($script:injected.DestinationPrefix -ne '1.1.1.1/32' -or $script:injected.RouteMetric -ne 1) { throw 'Unexpected route identity; refusing cleanup' }
$script:injected | Remove-NetRoute -Confirm:$false -ErrorAction Stop
$script:injected=$null
}
try {
if (!([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Administrator required' }
if ($ObserveSeconds -lt 30 -or $ObserveSeconds -gt 600) { throw 'Observation must be between30 and600 seconds' }
$before=Status
if ($before.State -ne 'connected' -or !$before.SystemRoutesVerified -or !$before.EnginePid) { throw 'An installed VPN with verified routes is required' }
$report.Before=$before | Select-Object State,ActiveName,EnginePid,XrayPid,Restarts,SystemRoutesVerified
if (Get-NetRoute -DestinationPrefix '1.1.1.1/32' -ErrorAction SilentlyContinue) { throw 'A pre-existing control host route must be preserved; test skipped' }
$physical=Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | ForEach-Object {
Get-NetRoute -InterfaceIndex $_.ifIndex -DestinationPrefix '0.0.0.0/0' -PolicyStore ActiveStore -ErrorAction SilentlyContinue
} | Where-Object {$_.NextHop -ne '0.0.0.0'} | Sort-Object RouteMetric | Select-Object -First 1
if (!$physical) { throw 'No physical IPv4 default route found' }
Save 'inject one temporary public control route'
$timer=[Diagnostics.Stopwatch]::StartNew()
$injected=New-NetRoute -DestinationPrefix '1.1.1.1/32' -InterfaceIndex $physical.InterfaceIndex -NextHop $physical.NextHop -RouteMetric 1 -PolicyStore ActiveStore
$detected=$null; $restarted=$null
do {
$status=Status
if ($status.SystemRoutesVerified -eq $false -and !$detected) {
$detected=$status
$report.DetectionSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)
$report.Degraded=$status | Select-Object State,EnginePid,Restarts,SystemRoutesVerified
if ($status.State -eq 'connected') { throw 'False connected status survived route loss' }
Save 'route loss detected; await same-server repair'
}
if ($status.EnginePid -and $status.EnginePid -ne $before.EnginePid) { $restarted=$status; break }
Start-Sleep -Milliseconds 200
} while ($timer.Elapsed.TotalSeconds -lt 50)
if (!$detected) { throw 'Route bypass was not detected' }
if (!$restarted) { throw 'Local tunnel repair did not restart the engine' }
$report.RestartSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)
Remove-InjectedRoute
Save 'control route restored; verify recovery'
$timer.Restart()
do {
$status=Status
if ($status.State -eq 'connected' -and $status.SystemRoutesVerified) { break }
Start-Sleep -Milliseconds 300
} while ($timer.Elapsed.TotalSeconds -lt 60)
if ($status.State -ne 'connected' -or !$status.SystemRoutesVerified) { throw 'Verified VPN routes did not recover' }
if ($status.ActiveId -ne $before.ActiveId -or $status.SelectedId -ne $before.SelectedId) { throw 'A local route fault changed the selected VPN server' }
$report.RecoverySeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)
$report.After=$status | Select-Object State,ActiveName,EnginePid,XrayPid,Restarts,SystemRoutesVerified
$baseline=$status; $samples=0; $httpsChecks=0
Save 'passive observation'
$timer.Restart()
do {
$status=Status; $samples++
if ($status.State -ne 'connected' -or !$status.SystemRoutesVerified -or $status.EnginePid -ne $baseline.EnginePid -or $status.ActiveId -ne $baseline.ActiveId) { throw 'Verified tunnel changed during passive observation' }
if ($samples % 5 -eq 1) {
$code=& curl.exe --silent --noproxy '*' --max-time 8 --output NUL --write-out '%{http_code}' 'https://cp.cloudflare.com/generate_204' 2>$null
if ($LASTEXITCODE -ne 0 -or $code -ne '204') { throw 'System HTTPS failed after route recovery' }
$httpsChecks++
}
Start-Sleep -Seconds 2
} while ($timer.Elapsed.TotalSeconds -lt $ObserveSeconds)
$report.Observation=[ordered]@{Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Samples=$samples;SystemHttpsChecks=$httpsChecks;UnplannedRestarts=0;ServerChanges=0}
$report.RouteCleanupVerified=!(Get-NetRoute -DestinationPrefix '1.1.1.1/32' -ErrorAction SilentlyContinue)
if (!$report.RouteCleanupVerified) { throw 'Temporary control route still exists' }
$report.Completed=$true; Save 'complete'
} catch {
$report.Failure=$_.Exception.Message
Save $report.Phase
exit 1
} finally {
Remove-InjectedRoute
}