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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user