[CmdletBinding()] param([switch]$Prepare, [switch]$InstallUpdate) $ErrorActionPreference = 'Stop' $repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $app = Join-Path $env:ProgramFiles 'QVPN/app/QVPN.exe' $report = Join-Path $repo 'research/installed-startup-verification.json' $preparedReport = Join-Path $repo 'research/startup-prepared-verification.json' $testDll = Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $administrator = ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) function Read-Status { $raw = & dotnet $testDll --client status if ($LASTEXITCODE -ne 0) { throw 'The service status pipe is unavailable' } $response = $raw | ConvertFrom-Json if (!$response.Ok -or !$response.Status) { throw 'The service status request failed' } return $response.Status } if ($Prepare) { if (!$administrator) { throw 'Preparation requires an administrator token' } try { $before = Read-Status if (!$before.DesiredConnected -or $before.Nodes.Count -eq 0) { throw 'This test requires an existing connected subscription' } if ($InstallUpdate) { $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).Hash -ne $expected) { throw 'Installer checksum mismatch' } $installer = Start-Process -FilePath $setup -ArgumentList '--silent' -WindowStyle Hidden -PassThru -Wait if ($installer.ExitCode -ne 0) { throw "Update failed: $($installer.ExitCode)" } } $configuration = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\QVPNService' if ($configuration.Start -ne 2 -or $configuration.DelayedAutoStart -eq 1) { throw 'The service still uses delayed or non-automatic startup' } foreach ($process in (Get-Process -Name QVPN -ErrorAction SilentlyContinue)) { if ($process.Path -eq $app) { Stop-Process -Id $process.Id -Force } } $service = Get-Service QVPNService Stop-Service -Name QVPNService $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) [pscustomobject]@{Prepared=$true;Nodes=$before.Nodes.Count;DesiredConnected=$before.DesiredConnected;DelayedAutoStart=$false;At=[DateTimeOffset]::Now.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $preparedReport -Encoding utf8 } catch { Start-Service QVPNService -ErrorAction SilentlyContinue [pscustomobject]@{Prepared=$false;Failure=$_.Exception.Message} | ConvertTo-Json | Set-Content -LiteralPath $preparedReport -Encoding utf8 exit 1 } exit 0 } if ($administrator) { throw 'Run verification from a regular Windows user token; only the preparation helper is elevated' } try { $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-Prepare') if ($InstallUpdate) { $arguments += '-InstallUpdate' } $helper = Start-Process -FilePath (Join-Path $PSHOME 'pwsh.exe') -Verb RunAs -WindowStyle Hidden -ArgumentList $arguments -PassThru $helper.WaitForExit() if ($helper.ExitCode -ne 0) { throw 'Elevated preparation did not complete' } $prepared = Get-Content -LiteralPath $preparedReport -Raw | ConvertFrom-Json if (!$prepared.Prepared) { throw 'Service stop/update preparation failed' } # Capture is read-only and does not trigger service startup. $capture = Start-Process -FilePath $app -ArgumentList '--capture-current', (Join-Path $repo 'research/startup-waiting.png') -PassThru -Wait if ($capture.ExitCode -ne 1 -or (Get-Service QVPNService).Status -ne 'Stopped') { throw 'Read-only capture unexpectedly started the service' } $timer = [Diagnostics.Stopwatch]::StartNew() Start-Process -FilePath $app -ArgumentList '--tray' | Out-Null $status = $null do { Start-Sleep -Seconds 1 try { $status = Read-Status } catch { $status = $null } if ($status -and $status.State -eq 'connected' -and $status.DesiredConnected -and $status.EnginePid) { break } } while ($timer.Elapsed.TotalSeconds -lt 60) if (!$status -or $status.State -ne 'connected') { throw 'The regular user interface did not automatically start/reconnect the stopped service' } if ($status.Nodes.Count -ne $prepared.Nodes) { throw 'The cached subscription was not preserved' } $recoverySeconds = [Math]::Round($timer.Elapsed.TotalSeconds,1) $http = $null foreach ($endpoint in @('https://cp.cloudflare.com/generate_204','https://www.gstatic.com/generate_204')) { $code = & curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' $endpoint if ($LASTEXITCODE -eq 0 -and $code -eq '204') { $http = $code; break } } if ($http -ne '204') { throw 'System HTTPS through the restored TUN failed on both health endpoints' } $capture = Start-Process -FilePath $app -ArgumentList '--capture-current', (Join-Path $repo 'research/startup-connected.png') -PassThru -Wait if ($capture.ExitCode -ne 0) { throw 'The connected UI capture failed' } [pscustomobject]@{ Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Version=(Get-Item $app).VersionInfo.FileVersion DelayedAutoStart=$false;ClientElevated=$administrator;RegularUserStartedService=$true Seconds=$recoverySeconds;State=$status.State;Nodes=$status.Nodes.Count KeyEnteredAgain=$false;HttpCode=$http;FullWindowsRebootTested=$false } | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8 Get-Content -LiteralPath $report } catch { # Restore startup when a verification step fails; this never imports/replaces the key. Start-Process -FilePath $app -ArgumentList '--tray' -ErrorAction SilentlyContinue | Out-Null [pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.Message} | ConvertTo-Json | Set-Content -LiteralPath $report -Encoding utf8 Get-Content -LiteralPath $report exit 1 }