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.
This commit is contained in:
Курнат Андрей
2026-10-30 06:15:08 +03:00
parent ef622e6229
commit aae6dd28dd
34 changed files with 2603 additions and 120 deletions
+14
View File
@@ -32,9 +32,13 @@ function Invoke-DotNet([string[]]$Arguments) {
$coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64'
$rulesRoot = Join-Path $projectRoot 'tools/rules'
$xrayRoot = Join-Path $projectRoot 'tools/xray-unpacked'
foreach ($coreFile in @('sing-box.exe', 'libcronet.dll', 'LICENSE')) {
if (-not (Test-Path -LiteralPath (Join-Path $coreRoot $coreFile) -PathType Leaf)) { throw "Missing packaged VPN core: $coreFile" }
}
foreach ($xrayFile in @('xray.exe', 'wintun.dll', 'LICENSE', 'LICENSE-Wintun')) {
if (-not (Test-Path -LiteralPath (Join-Path $xrayRoot $xrayFile) -PathType Leaf)) { throw "Missing packaged Xray core: $xrayFile. Run scripts/prepare-xray.ps1." }
}
if (-not (Test-Path -LiteralPath $rulesRoot) -or -not (Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File)) {
throw 'Missing routing rules in tools/rules. The installer never downloads dependencies.'
}
@@ -55,12 +59,22 @@ Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.App/QVPN
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.Service/QVPN.Service.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=false', '-o', (Join-Path $payloadRoot 'service'))
New-Item -ItemType Directory -Path (Join-Path $payloadRoot 'core'), (Join-Path $payloadRoot 'rules') -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $coreRoot 'sing-box.exe'), (Join-Path $coreRoot 'libcronet.dll'), (Join-Path $coreRoot 'LICENSE') -Destination (Join-Path $payloadRoot 'core')
Copy-Item -LiteralPath (Join-Path $xrayRoot 'xray.exe'), (Join-Path $xrayRoot 'wintun.dll'), (Join-Path $xrayRoot 'LICENSE-Wintun') -Destination (Join-Path $payloadRoot 'core')
Copy-Item -LiteralPath (Join-Path $xrayRoot 'LICENSE') -Destination (Join-Path $payloadRoot 'core/LICENSE-Xray')
Copy-Item -LiteralPath (Join-Path $projectRoot 'tools/Xray-core-v26.9.9-source.zip') -Destination (Join-Path $payloadRoot 'core/Xray-core-v26.9.9-source.zip')
Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File | Copy-Item -Destination (Join-Path $payloadRoot 'rules')
if (Test-Path -LiteralPath (Join-Path $projectRoot 'README.md')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'README.md') -Destination (Join-Path $payloadRoot 'README.md') }
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING') -Destination (Join-Path $payloadRoot 'core/COPYING') }
if (Test-Path -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip') -Destination (Join-Path $payloadRoot 'core/sing-box-1.14.2-source.zip') }
@'
QVPN uses unmodified Xray-core v26.9.9 (Windows amd64), copyright Project X contributors, for VLESS/Reality.
Xray is distributed under Mozilla Public License 2.0 in core/LICENSE-Xray.
The original upstream source is included in core/Xray-core-v26.9.9-source.zip.
Source: https://github.com/XTLS/Xray-core/tree/v26.9.9
Release: https://github.com/XTLS/Xray-core/releases/tag/v26.9.9
Wintun license is included in core/LICENSE-Wintun.
QVPN includes sing-box v1.14.2 (Windows amd64), copyright nekohasekai / SagerNet.
sing-box provides the Windows TUN, DNS handling and routing to the local Xray proxy.
sing-box is distributed under GNU GPL v3 or later with the notice in core/LICENSE.
Upstream source archive for the unmodified binary: core/sing-box-1.14.2-source.zip
Source tree and module/build instructions: https://github.com/SagerNet/sing-box/tree/v1.14.2
+197
View File
@@ -0,0 +1,197 @@
[CmdletBinding()]
param([switch]$InstallUpdate, [switch]$ResumeInstalled, [int]$ObserveSeconds = 120)
$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/happ-comparison-verification.json'
$happRoot = 'C:\Program Files\FlyFrogLLC\Happ'
$dataRoot = Join-Path $env:ProgramData 'QVPN'
$report = [ordered]@{ Completed=$false; Phase='preflight'; CapturedAt=[DateTimeOffset]::Now.ToString('o') }
$happWasRunning = $false
$before = $null
$backupPath = $null
function Save-Phase([string]$phase) {
$report.Phase = $phase
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8
}
function Send([string]$command, [string]$value='', [Nullable[bool]]$enabled=$null) {
$arguments = @($dll, '--client', $command)
if ($value) { $arguments += @('--value', $value) }
if ($null -ne $enabled) { $arguments += @('--enabled', $enabled.ToString().ToLowerInvariant()) }
$response = & dotnet @arguments | ConvertFrom-Json
if ($LASTEXITCODE -ne 0 -or !$response.Ok) { throw "Service command failed: $command" }
return $response.Status
}
function Read-Settings {
$raw = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes((Join-Path $dataRoot 'settings.dpapi')), $null, [Security.Cryptography.DataProtectionScope]::LocalMachine)
try { return [Text.Encoding]::UTF8.GetString($raw) | ConvertFrom-Json }
finally { [Array]::Clear($raw, 0, $raw.Length) }
}
function Wait-Connected([int]$seconds=60) {
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
try { $status = Send 'status' } catch { Start-Sleep -Milliseconds 400; continue }
if ($status.State -eq 'connected' -and $status.EnginePid -and $status.XrayPid) {
return [pscustomobject]@{Connected=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Status=$status}
}
Start-Sleep -Milliseconds 400
} while ($timer.Elapsed.TotalSeconds -lt $seconds)
return [pscustomobject]@{Connected=$false;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,2);Status=$status}
}
function Measure-Path([bool]$proxy) {
$results = @('https://cp.cloudflare.com/generate_204','https://www.gstatic.com/generate_204','https://www.google.com/generate_204') | ForEach-Object -Parallel {
$arguments = @('--silent','--head','--max-time','8','--output','NUL','--write-out','%{http_code}|%{time_total}|%{time_namelookup}')
if ($using:proxy) { $arguments += @('--proxy','http://127.0.0.1:17891') } else { $arguments += @('--noproxy','*') }
$raw = & curl.exe @arguments $_ 2>$null
$exitCode = $LASTEXITCODE
$fields = "$raw".Split('|')
[pscustomobject]@{Endpoint=([Uri]$_).Host;Http=[int]$fields[0];CurlExit=$exitCode;Seconds=$fields[1];DnsSeconds=$fields[2]}
} -ThrottleLimit 3
return @($results)
}
function Measure-Download {
$raw = & curl.exe --silent --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}|%{time_total}|%{size_download}|%{speed_download}' 'https://speed.cloudflare.com/__down?bytes=1000000' 2>$null
$exitCode=$LASTEXITCODE; $fields="$raw".Split('|')
return [pscustomobject]@{Http=[int]$fields[0];CurlExit=$exitCode;Seconds=$fields[1];Bytes=[long]$fields[2];BytesPerSecond=$fields[3];Complete=($exitCode -eq 0 -and [long]$fields[2] -eq 1000000)}
}
function Read-Exit([bool]$proxy, [int]$port=17891) {
$arguments=@('--silent','--max-time','8')
if ($proxy) { $arguments+=@('--proxy',"socks5h://127.0.0.1:$port") } else { $arguments+=@('--noproxy','*') }
$value = & curl.exe @arguments 'https://api4.ipify.org' 2>$null
$parsed=$null
if ($LASTEXITCODE -eq 0 -and [Net.IPAddress]::TryParse("$value".Trim(), [ref]$parsed)) { return $parsed.ToString() }
return $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 between 30 and 600 seconds' }
$before=Send 'status'; $settings=Read-Settings
if (!$before.Nodes.Count) { throw 'Existing subscription required' }
$version=([xml](Get-Content -LiteralPath (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
$setup=Join-Path $repo "dist/QVPN-Setup-$version.exe"
if ($InstallUpdate) {
$expected=(Get-Content -LiteralPath (Join-Path $repo "dist/QVPN-Setup-$version.sha256") -Raw).Split(' ')[0]
if ((Get-FileHash -LiteralPath $setup).Hash -ne $expected) { throw 'Installer checksum mismatch' }
}
$backupPath=Join-Path $dataRoot ("settings.dpapi.before-$version-happ-"+[DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
Copy-Item -LiteralPath (Join-Path $dataRoot 'settings.dpapi') -Destination $backupPath
if ($ResumeInstalled) {
if ($InstallUpdate) { throw 'ResumeInstalled and InstallUpdate cannot be combined' }
$report=Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json -AsHashtable
if (!$report.Old -or !$report.Happ) { throw 'A completed reference baseline is required for resume' }
$report.Completed=$false
foreach ($key in @('Failure','OriginalSettingsRestored','NeedsHappUiRestore','RestoreFailureType')) { $report.Remove($key) }
$report.Reference.SameCachedEndpoint=$null
$report.Reference.HappEndpointAvailable=$false
$report.ExitCheckNote='Exit addresses can vary across new connections, including the same Xray SOCKS process. Compare each VPN path with the physical baseline instead of requiring identical VPN egress addresses.'
$node=$settings.Nodes | Where-Object {$_.Name -eq $report.Location} | Select-Object -First 1
if (!$node) { throw 'The compared location is missing from installed settings' }
$happWasRunning=(Get-Service HappService).Status -eq 'Running'
Stop-Service HappService
$null=Send 'disconnect'
Save-Phase 'verify physical exit before installed tunnel'
$physicalExit=Read-Exit $false
} else {
$preferences=Get-ItemProperty 'HKCU:\Software\Happ\OrganizationDefaults\Preferences'
$node=$settings.Nodes | Where-Object {$_.Host -eq $preferences.lastServerHost -and $_.Port -eq [int]$preferences.lastServerPort} | Select-Object -First 1
$sameEndpoint=$null -ne $node
if (!$node) { $node=$settings.Nodes | Where-Object {$_.Name -like ('*'+$preferences.lastServerName+'*')} | Select-Object -First 1 }
if (!$node) { throw 'Matching Happ location is absent from QVPN cache' }
$report.Location=$node.Name
$report.Reference=[ordered]@{HappVersion='4.5.4 (625)';HappTunProvider=(Get-ItemProperty 'HKCU:\Software\Happ\OrganizationDefaults\Preferences\AdvancedSettings').tunProvider;HappEndpointAvailable=[bool]$preferences.lastServerHost;SameCachedEndpoint=if($preferences.lastServerHost){$sameEndpoint}else{$null};QvpnFlow=$node.Flow;QvpnFingerprint=$node.Fingerprint;QvpnOldVersion=(Get-Item 'C:\Program Files\QVPN\service\QVPN.Service.exe').VersionInfo.FileVersion}
$happWasRunning=(Get-Service HappService).Status -eq 'Running'
if (!(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'})) { throw 'An active Happ tunnel is required for the reference baseline' }
$null=Send 'disconnect'
Save-Phase 'Happ baseline'
$report.Happ=[ordered]@{SystemHttps=(Measure-Path $false);Download=(Measure-Download)}
$happExit=Read-Exit $false
Save-Phase 'release Happ tunnel'
Get-Process Happ -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq (Join-Path $happRoot 'Happ.exe')} | Stop-Process -Force
Stop-Service HappService
(Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(20))
$timer=[Diagnostics.Stopwatch]::StartNew()
while (Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'}) {
if ($timer.Elapsed.TotalSeconds -gt 20) { throw 'Happ tunnel did not stop' }
Start-Sleep -Milliseconds 300
}
$physicalExit=Read-Exit $false
$null=Send 'select' $node.Id; $null=Send 'failover' '' $false
Save-Phase 'QVPN old baseline'
$null=Send 'connect'; $connection=Wait-Connected 45
$report.Old=[ordered]@{Connected=$connection.Connected;ConnectSeconds=$connection.Seconds;ActiveName=$connection.Status.ActiveName;SystemHttps=(Measure-Path $false);ProxyHttps=(Measure-Path $true);Download=(Measure-Download)}
$null=Send 'disconnect'
if ($InstallUpdate) {
Save-Phase 'install update with encrypted settings preserved'
for ($attempt=0; $attempt -lt 2; $attempt++) {
$installer=Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait
if ($installer.ExitCode -eq 0) { break }
if ($attempt -eq 1) { throw 'Installer failed after two attempts; previous version rollback retained' }
Start-Sleep -Seconds 5
}
}
}
$afterSettings=Read-Settings
$report.SubscriptionRetained=$afterSettings.SubscriptionUrl -ceq $settings.SubscriptionUrl
if (!$report.SubscriptionRetained) { throw 'Subscription changed during installation' }
$null=Send 'failover' '' $before.AutoFailover
Save-Phase 'connect updated QVPN'
$null=Send 'connect'; $connection=Wait-Connected
if (!$connection.Connected) { throw 'Updated system VPN did not connect' }
if ([version]$version -ge [version]'1.0.5' -and !$connection.Status.SystemRoutesVerified) { throw 'Updated VPN did not verify Windows route ownership' }
$report.New=[ordered]@{Version=(Get-Item 'C:\Program Files\QVPN\service\QVPN.Service.exe').VersionInfo.FileVersion;ConnectSeconds=$connection.Seconds;ActiveName=$connection.Status.ActiveName;SystemHttps=(Measure-Path $false);ProxyHttps=(Measure-Path $true);Download=(Measure-Download)}
$report.New.SystemRoutesVerified=$connection.Status.SystemRoutesVerified
$systemExits=@(1..3 | ForEach-Object { Read-Exit $false } | Where-Object {$_})
$proxyExits=@(1..3 | ForEach-Object { Read-Exit $true } | Where-Object {$_})
# An upstream VPN can assign a different egress IP to each fresh connection.
# Require both paths to differ from the physical connection, not from each other.
$report.New.ExitVerified=($physicalExit -and $systemExits.Count -ge 2 -and $proxyExits.Count -ge 2 -and !($systemExits -contains $physicalExit) -and !($proxyExits -contains $physicalExit))
$report.New.SystemExitSamples=$systemExits.Count
$report.New.ProxyExitSamples=$proxyExits.Count
$report.New.DistinctProxyExits=@($proxyExits | Sort-Object -Unique).Count
if (!$ResumeInstalled) { $report.Reference.HappExitDiffersFromPhysical=($happExit -and $physicalExit -and $happExit -ne $physicalExit) }
$report.New.SystemRouteUsesQvpn=@(Find-NetRoute -RemoteIPAddress '1.1.1.1' | Where-Object {$_.InterfaceAlias -eq 'QVPN'}).Count -gt 0
if (!$report.New.SystemRouteUsesQvpn) { throw 'System route does not use QVPN' }
if (!$report.New.ExitVerified) { throw 'System/proxy/physical exit comparison did not verify the VPN' }
if (!($report.New.SystemHttps | Where-Object {$_.Http -eq 204 -and $_.CurlExit -eq 0})) { throw 'System HTTPS failed after update' }
if (!($report.New.ProxyHttps | Where-Object {$_.Http -eq 204 -and $_.CurlExit -eq 0})) { throw 'Proxy HTTPS failed after update' }
if (!$report.New.Download.Complete) { throw 'Controlled download did not complete after update' }
if ($report.New.ActiveName -ne $node.Name) { throw 'Comparison location changed' }
Save-Phase 'recovery and passive observation'
$verification=Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -WindowStyle Hidden -ArgumentList @('-NoProfile','-File',(Join-Path $PSScriptRoot 'verify-stability-installed.ps1'),'-ObserveSeconds',$ObserveSeconds.ToString()) -PassThru -Wait
if ($verification.ExitCode -ne 0) { throw 'Installed recovery/stability verification failed' }
$report.RecoveryReport='installed-stability-verification.json'
# Return the original manual/automatic preference. The verified connection stays up.
$current=Send 'status'
if (!$before.SelectedId) { $null=Send 'select' }
elseif ($before.SelectedId -ne $node.Id) {
$oldSelected=$settings.Nodes | Where-Object {$_.Id -eq $before.SelectedId} | Select-Object -First 1
$replacement=$current.Nodes | Where-Object {$_.Id -eq $before.SelectedId} | Select-Object -First 1
if (!$replacement) {
$matches=@($current.Nodes | Where-Object {$_.Name -eq $oldSelected.Name})
if ($matches.Count -eq 1) { $replacement=$matches[0] }
}
if ($replacement) { $null=Send 'select' $replacement.Id }
}
$final=Wait-Connected
if (!$final.Connected) { throw 'Final connection did not recover after restoring selection' }
$report.Final=[ordered]@{State=$final.Status.State;Location=$final.Status.ActiveName;Nodes=$final.Status.Nodes.Count;HappStopped=((Get-Service HappService).Status -eq 'Stopped')}
$report.Completed=$true; Save-Phase 'complete'
} catch {
$report.Failure=$_.Exception.Message
try {
$null=Send 'disconnect'
if ($backupPath -and (Test-Path -LiteralPath $backupPath)) {
Stop-Service QVPNService
(Get-Service QVPNService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(20))
Copy-Item -LiteralPath $backupPath -Destination (Join-Path $dataRoot 'settings.dpapi') -Force
Start-Service QVPNService
$report.OriginalSettingsRestored=$true
}
} catch { $report.RestoreFailureType=$_.Exception.GetType().Name }
if ($happWasRunning) { Start-Service HappService -ErrorAction SilentlyContinue; $report.NeedsHappUiRestore=$true }
Save-Phase $report.Phase
exit 1
}
+17
View File
@@ -0,0 +1,17 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$toolsRoot = Join-Path $root 'tools'
$archive = Join-Path $toolsRoot 'Xray-windows-64-v26.9.9.zip'
$source = Join-Path $toolsRoot 'Xray-core-v26.9.9-source.zip'
# SHA256 published in the official GitHub release API and Xray-windows-64.zip.dgst.
$expected = '244DEABA2098C2964E49BBA90DF3707777E5F5F428A82D2F29604015F24BEEC2'
New-Item -ItemType Directory -Path $toolsRoot -Force | Out-Null
if (!(Test-Path -LiteralPath $archive)) { Invoke-WebRequest -Uri 'https://github.com/XTLS/Xray-core/releases/download/v26.9.9/Xray-windows-64.zip' -OutFile $archive -TimeoutSec 240 }
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $expected) { throw 'Xray upstream archive checksum mismatch' }
if (!(Test-Path -LiteralPath $source)) { Invoke-WebRequest -Uri 'https://codeload.github.com/XTLS/Xray-core/zip/refs/tags/v26.9.9' -OutFile $source -TimeoutSec 240 }
# Pin the source ZIP fetched from the official v26.9.9 tag alongside this binary.
if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ne '33871A5B7673418708BEBA3EAA76028F941CE77986003B24E48A0A1A5F01425E') { throw 'Xray upstream source archive checksum mismatch' }
Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $toolsRoot 'xray-unpacked') -Force
Write-Host 'Verified Xray-core v26.9.9 prepared (same 52a412d core revision as the inspected Happ Windows client).'
+150
View File
@@ -0,0 +1,150 @@
[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
}
@@ -0,0 +1,93 @@
[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
}
+72
View File
@@ -0,0 +1,72 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$dll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
$report = Join-Path $repo 'research/xray-upgrade-verification.json'
$phase = 'preflight'; $happWasRunning = $false
function Save-Phase([string]$name) {
$script:phase = $name
[pscustomobject]@{Completed=$false;Phase=$name;At=[DateTimeOffset]::Now.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8
}
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
}
try {
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
if(!([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator required'}
$version=([xml](Get-Content (Join-Path $repo 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version
$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 checksum mismatch'}
$before=Send 'status'; if(!$before.Nodes.Count){throw 'Cached subscription missing'}
$zurich=$before.Nodes | Where-Object {$_.Name -like '*Цюрих*'} | Select-Object -First 1
if(!$zurich){throw 'Zurich is missing from subscription'}
Save-Phase 'refresh current provider parameters'
$data=Join-Path $env:ProgramData 'QVPN'
Copy-Item -LiteralPath (Join-Path $data 'settings.dpapi') -Destination (Join-Path $data ("settings.dpapi.before-$version-"+[DateTime]::UtcNow.ToString('yyyyMMddHHmmss')))
$refreshed=Send 'refresh'
$zurich=$refreshed.Nodes | Where-Object {$_.Name -eq $zurich.Name} | Select-Object -First 1
if(!$zurich){throw 'Zurich is missing after refreshing provider parameters'}
Save-Phase 'release competing HAPP tunnel'
$happ=Get-Service HappService -ErrorAction SilentlyContinue
$happWasRunning=$happ -and $happ.Status -eq 'Running'
Get-Process Happ -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq 'C:\Program Files\FlyFrogLLC\Happ\Happ.exe'} | Stop-Process -Force
if($happWasRunning){Stop-Service HappService; (Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30))}
Start-Sleep -Seconds 2
if(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Name -like '*happ*' -and $_.Status -eq 'Up'}){throw 'HAPP adapter is still active'}
$null=Send 'select' $zurich.Id
Save-Phase 'install Xray update preserving encrypted settings'
$timer=[Diagnostics.Stopwatch]::StartNew()
for($attempt=0;$attempt -lt 2;$attempt++) {
$installer=Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait
if($installer.ExitCode -eq 0){break}
if($attempt -eq 1){throw 'Installer failed twice'}
Start-Sleep -Seconds 5
}
Save-Phase 'connect installed Xray to Zurich'
$null=Send 'connect'; $timer.Restart()
do {
Start-Sleep -Milliseconds 250
$status=Send 'status'
if($status.State -eq 'connected' -and $status.VpnCore -eq 'Xray' -and $status.XrayPid){break}
}while($timer.Elapsed.TotalSeconds -lt 60)
if($status.State -ne 'connected' -or $status.VpnCore -ne 'Xray'){throw 'Installed Xray connection did not complete'}
if($status.ActiveName -ne $zurich.Name){throw 'Selected Zurich was not retained'}
$connectSeconds=[Math]::Round($timer.Elapsed.TotalSeconds,2)
Save-Phase 'verify installed Xray recovery and continuous TUN traffic'
$verification=Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',(Join-Path $PSScriptRoot 'verify-stability-installed.ps1'),'-ObserveSeconds','300') -PassThru -Wait
if($verification.ExitCode -ne 0){throw 'Installed recovery/stability verification failed'}
# Return the competing client to the state observed before this diagnostic run.
# The updated QVPN service and selected location remain installed for normal use.
if($happWasRunning -and !$before.DesiredConnected){$null=Send 'disconnect'; Start-Service HappService}
[pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Version=$version;ConnectionSeconds=$connectSeconds;VpnCore='Xray';Location='Zurich';KeyRetained=$true;HappStopped=$happWasRunning;HappRestored=($happWasRunning -and !$before.DesiredConnected);RecoveryReport='installed-stability-verification.json'} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8
} catch {
try{$null=Send 'disconnect'}catch{}
if($happWasRunning){Start-Service HappService -ErrorAction SilentlyContinue}
[pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Phase=$phase;Failure=$_.Exception.Message;HappRestored=$happWasRunning} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8
exit 1
}