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.
198 lines
14 KiB
PowerShell
198 lines
14 KiB
PowerShell
[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
|
|
}
|