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