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