95 lines
6.6 KiB
PowerShell
95 lines
6.6 KiB
PowerShell
[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'}
|
|
$setup=Join-Path $repo 'dist/QVPN-Setup-1.0.0.exe'
|
|
$expected=(Get-Content (Join-Path $repo 'dist/QVPN-Setup-1.0.0.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
|
|
}
|