Files
QVPN/scripts/compare-happ-installed.ps1
T
Курнат Андрей aae6dd28dd Release QVPN 1.0.5 with Xray and resilient tunnel recovery
Integrate Xray 26.9.9 with the sing-box system tunnel and verify Windows routes during health checks.

Improve endpoint selection, network recovery, subscription refresh deadlines, diagnostics, packaging, and regression coverage.
2026-10-30 06:15:08 +03:00

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
}