[CmdletBinding()] param([Parameter(Mandatory=$true)][string]$OwnerSid) $ErrorActionPreference='Stop' $repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $report=Join-Path $repo 'research/installed-verification.json' $dll=Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' $root=Join-Path $env:ProgramFiles 'QVPN' $happWasRunning=$false $checks=[Collections.Generic.List[object]]::new() function Send([string]$command,[string]$valueFile='') { $arguments=@($dll,'--client',$command) if($valueFile){$arguments+=@('--value-file',$valueFile)} $raw=& dotnet @arguments if($raw -notmatch '^\{'){throw "QVPN command unavailable: $command"} $response=$raw|ConvertFrom-Json if(!$response.Ok){throw "QVPN command failed: $command"} return $response.Status } function Wait-Connected([int]$seconds=60,[int]$DifferentEngine=0) { $timer=[Diagnostics.Stopwatch]::StartNew() do { Start-Sleep -Seconds 2 try {$status=Send 'status';if($status.State -eq 'connected' -and (!$DifferentEngine -or $status.EnginePid -ne $DifferentEngine)){return $status}}catch{} }while($timer.Elapsed.TotalSeconds -lt $seconds) throw 'QVPN did not establish a checked connection within the verification window' } function Write-Report([bool]$success,[string]$failure='') { [pscustomobject]@{Completed=$success;At=[DateTimeOffset]::Now.ToString('o');Failure=$failure;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8 } try { $identity=[Security.Principal.WindowsIdentity]::GetCurrent() if(![Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Administrator token is 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 -Algorithm SHA256).Hash -ne $expected){throw 'Installer hash mismatch'} $install=Start-Process -FilePath $setup -ArgumentList @('--silent','--owner',$OwnerSid) -PassThru if(!$install.WaitForExit(60000)){throw 'Installer still running'} if($install.ExitCode -ne 0){throw "Installer failed with exit code $($install.ExitCode)"} $service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'" if(!$service -or $service.State -ne 'Running' -or $service.StartName -ne 'LocalSystem'){throw 'Installed service identity/state check failed'} $owner=Get-ItemPropertyValue 'HKLM:\SOFTWARE\QVPN' 'OwnerSid' if($owner -ne $OwnerSid){throw 'Installed owner mismatch'} $dataAcl=Get-Acl -LiteralPath (Join-Path $env:ProgramData 'QVPN') $unexpected=@($dataAcl.Access|Where-Object {$_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18','S-1-5-32-544')}) if($unexpected.Count -gt 0){throw 'Data ACL includes an unexpected reader'} $checks.Add([pscustomobject]@{Scenario='Offline install, protected data and LocalSystem service';Passed=$true}) $import=Send 'import' 'C:\Tools\QVPN-private\subscription-url.txt' $checks.Add([pscustomobject]@{Scenario='Live Blanc subscription import';Passed=($import.Nodes.Count -eq 50);Nodes=$import.Nodes.Count}) # Replace the running HAPP session only after QVPN has a validated subscription. $happ=Get-Service -Name HappService -ErrorAction SilentlyContinue $happWasRunning=$happ -and $happ.Status -eq 'Running' Get-Process Happ -ErrorAction SilentlyContinue | ForEach-Object {if($_.Path -eq 'C:\Program Files\FlyFrogLLC\Happ\Happ.exe'){Stop-Process -Id $_.Id -Force}} if($happWasRunning){Stop-Service -Name HappService; (Get-Service HappService).WaitForStatus('Stopped',[TimeSpan]::FromSeconds(30))} Start-Sleep -Seconds 2 $null=Send 'connect' $status=Wait-Connected $adapter=Get-NetAdapter -Name QVPN -ErrorAction Stop if($adapter.Status -ne 'Up'){throw 'QVPN TUN adapter is not up'} $routes=@(Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 | Where-Object {$_.DestinationPrefix -in @('0.0.0.0/1','128.0.0.0/1','0.0.0.0/0')}) if($routes.Count -eq 0){throw 'No public IPv4 routes through QVPN TUN'} $http=& curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' https://cp.cloudflare.com/generate_204 if($LASTEXITCODE -ne 0 -or $http -ne '204'){throw 'System route HTTPS request failed'} $checks.Add([pscustomobject]@{Scenario='Windows TUN and HTTPS through system routes';Passed=$true;Adapter=$adapter.Name;Routes=$routes.Count;HttpCode=$http;State=$status.State}) $previousEngine=$status.EnginePid $engine=Get-Process -Id $previousEngine if($engine.Path -ne (Join-Path $root 'core/sing-box.exe')){throw 'Unexpected VPN process executable'} $timer=[Diagnostics.Stopwatch]::StartNew() Stop-Process -Id $previousEngine -Force $status=Wait-Connected -DifferentEngine $previousEngine if($status.EnginePid -eq $previousEngine){throw 'VPN engine was not replaced'} $checks.Add([pscustomobject]@{Scenario='TUN engine crash recovery without re-entering key';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);Restarts=$status.Restarts}) $oldEngine=$status.EnginePid $service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'" $process=Get-Process -Id $service.ProcessId if($process.Path -ne (Join-Path $root 'service/QVPN.Service.exe')){throw 'Unexpected service executable'} $oldService=$process.Id $timer.Restart() Stop-Process -Id $oldService -Force Start-Sleep -Seconds 2 $orphan=Get-Process -Id $oldEngine -ErrorAction SilentlyContinue if($orphan -and $orphan.Path -eq (Join-Path $root 'core/sing-box.exe')){throw 'VPN process survived daemon job disposal'} $status=Wait-Connected $service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'" if($service.ProcessId -eq $oldService){throw 'SCM did not replace crashed service'} $checks.Add([pscustomobject]@{Scenario='SCM service crash recovery, no orphan engine, persisted key and connection intent';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);State=$status.State;Nodes=$status.Nodes.Count}) & sc.exe qfailure QVPNService | Out-File -LiteralPath (Join-Path $repo 'research/scm-recovery.txt') -Encoding utf8 Write-Report $true }catch{ $failure=$_.Exception.GetType().Name+': '+$_.Exception.Message try{$null=Send 'disconnect'}catch{Stop-Service QVPNService -ErrorAction SilentlyContinue} if($happWasRunning){Start-Service HappService -ErrorAction SilentlyContinue} Write-Report $false $failure exit 1 }