Implement QVPN Windows client for BlancVPN with resilient VPN service
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$Configuration = 'Release', [switch]$SkipTests)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$distributionRoot = Join-Path $projectRoot 'dist'
|
||||
$payloadRoot = Join-Path $distributionRoot 'payload'
|
||||
$setupPublishRoot = Join-Path $distributionRoot 'setup-publish'
|
||||
|
||||
function Reset-BuildDirectory([string]$Path) {
|
||||
$absoluteTarget = [IO.Path]::GetFullPath($Path)
|
||||
$allowedRoot = [IO.Path]::GetFullPath($distributionRoot) + [IO.Path]::DirectorySeparatorChar
|
||||
if (-not $absoluteTarget.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove outside dist: $absoluteTarget"
|
||||
}
|
||||
if (Test-Path -LiteralPath $absoluteTarget) {
|
||||
if ((Get-Item -LiteralPath $absoluteTarget -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "Build directory is a reparse point: $absoluteTarget"
|
||||
}
|
||||
$linkedEntry = Get-ChildItem -LiteralPath $absoluteTarget -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } | Select-Object -First 1
|
||||
if ($linkedEntry) { throw "Build directory contains a reparse point: $($linkedEntry.FullName)" }
|
||||
Remove-Item -LiteralPath $absoluteTarget -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $absoluteTarget -Force | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-DotNet([string[]]$Arguments) {
|
||||
& dotnet @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet failed ($LASTEXITCODE): $($Arguments -join ' ')" }
|
||||
}
|
||||
|
||||
$coreRoot = Join-Path $projectRoot 'tools/core-unpacked/sing-box-1.14.2-windows-amd64'
|
||||
$rulesRoot = Join-Path $projectRoot 'tools/rules'
|
||||
foreach ($coreFile in @('sing-box.exe', 'libcronet.dll', 'LICENSE')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $coreRoot $coreFile) -PathType Leaf)) { throw "Missing packaged VPN core: $coreFile" }
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $rulesRoot) -or -not (Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File)) {
|
||||
throw 'Missing routing rules in tools/rules. The installer never downloads dependencies.'
|
||||
}
|
||||
foreach ($project in @('src/QVPN.App/QVPN.App.csproj', 'src/QVPN.Service/QVPN.Service.csproj', 'src/QVPN.Setup/QVPN.Setup.csproj')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $projectRoot $project))) { throw "Missing project: $project" }
|
||||
}
|
||||
|
||||
if (-not $SkipTests) {
|
||||
$testProjects = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'tests') -Filter '*.csproj' -File -Recurse -ErrorAction SilentlyContinue)
|
||||
foreach ($testProject in $testProjects) { Invoke-DotNet -Arguments @('run', '--project', $testProject.FullName, '-c', $Configuration) }
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $distributionRoot -Force | Out-Null
|
||||
Reset-BuildDirectory $payloadRoot
|
||||
Reset-BuildDirectory $setupPublishRoot
|
||||
Write-Host 'Publishing Windows UI and service with the bundled .NET runtime...'
|
||||
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.App/QVPN.App.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true', '-p:IncludeNativeLibrariesForSelfExtract=true', '-o', (Join-Path $payloadRoot 'app'))
|
||||
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.Service/QVPN.Service.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=false', '-o', (Join-Path $payloadRoot 'service'))
|
||||
New-Item -ItemType Directory -Path (Join-Path $payloadRoot 'core'), (Join-Path $payloadRoot 'rules') -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $coreRoot 'sing-box.exe'), (Join-Path $coreRoot 'libcronet.dll'), (Join-Path $coreRoot 'LICENSE') -Destination (Join-Path $payloadRoot 'core')
|
||||
Get-ChildItem -LiteralPath $rulesRoot -Filter '*.srs' -File | Copy-Item -Destination (Join-Path $payloadRoot 'rules')
|
||||
if (Test-Path -LiteralPath (Join-Path $projectRoot 'README.md')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'README.md') -Destination (Join-Path $payloadRoot 'README.md') }
|
||||
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot '../src/QVPN.Setup/COPYING') -Destination (Join-Path $payloadRoot 'core/COPYING') }
|
||||
if (Test-Path -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip')) { Copy-Item -LiteralPath (Join-Path $projectRoot 'tools/sing-box-1.14.2-source.zip') -Destination (Join-Path $payloadRoot 'core/sing-box-1.14.2-source.zip') }
|
||||
@'
|
||||
QVPN includes sing-box v1.14.2 (Windows amd64), copyright nekohasekai / SagerNet.
|
||||
sing-box is distributed under GNU GPL v3 or later with the notice in core/LICENSE.
|
||||
Upstream source archive for the unmodified binary: core/sing-box-1.14.2-source.zip
|
||||
Source tree and module/build instructions: https://github.com/SagerNet/sing-box/tree/v1.14.2
|
||||
Release and original archive: https://github.com/SagerNet/sing-box/releases/tag/v1.14.2
|
||||
The bundled rule sets are from https://github.com/SagerNet/sing-geosite and https://github.com/SagerNet/sing-geoip.
|
||||
See the respective repositories for source data and notices.
|
||||
'@ | Set-Content -LiteralPath (Join-Path $payloadRoot 'THIRD-PARTY-NOTICES.txt') -Encoding utf8
|
||||
|
||||
Get-ChildItem -LiteralPath $payloadRoot -Filter '*.pdb' -Recurse -File | Remove-Item -Force
|
||||
$manifest = [ordered]@{}
|
||||
foreach ($file in (Get-ChildItem -LiteralPath $payloadRoot -Recurse -File | Sort-Object FullName)) {
|
||||
$relativePath = [IO.Path]::GetRelativePath($payloadRoot, $file.FullName)
|
||||
$manifest[$relativePath] = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $payloadRoot 'payload-manifest.json') -Encoding utf8
|
||||
$payloadZip = Join-Path $distributionRoot 'payload.zip'
|
||||
if (Test-Path -LiteralPath $payloadZip) { Remove-Item -LiteralPath $payloadZip -Force }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($payloadRoot, $payloadZip, [IO.Compression.CompressionLevel]::Optimal, $false)
|
||||
Write-Host 'Embedding the complete payload in the offline Windows installer...'
|
||||
Invoke-DotNet -Arguments @('publish', (Join-Path $projectRoot 'src/QVPN.Setup/QVPN.Setup.csproj'), '-c', $Configuration, '-r', 'win-x64', '--self-contained', 'true', '-o', $setupPublishRoot)
|
||||
$installer = Join-Path $distributionRoot 'QVPN-Setup-1.0.0.exe'
|
||||
Copy-Item -LiteralPath (Join-Path $setupPublishRoot 'QVPN-Setup.exe') -Destination $installer -Force
|
||||
$installerHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash
|
||||
"$installerHash QVPN-Setup-1.0.0.exe" | Set-Content -LiteralPath (Join-Path $distributionRoot 'QVPN-Setup-1.0.0.sha256') -Encoding ascii
|
||||
Write-Host "Installer: $installer"
|
||||
Write-Host "SHA256: $installerHash"
|
||||
@@ -0,0 +1,94 @@
|
||||
[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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$tools=Join-Path $root 'tools'
|
||||
New-Item -ItemType Directory -Path $tools -Force | Out-Null
|
||||
$archive=Join-Path $tools 'core.zip'
|
||||
Invoke-WebRequest 'https://github.com/SagerNet/sing-box/releases/download/v1.14.2/sing-box-1.14.2-windows-amd64.zip' -OutFile $archive
|
||||
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne 'C2D8BFFF918755808781DFDEEB8581B6C91EB3A243D9A7B55483CFC0C0684D32') {throw 'VPN core archive hash mismatch'}
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $tools 'core-unpacked') -Force
|
||||
$rules=Join-Path $tools 'rules'
|
||||
New-Item -ItemType Directory -Path $rules -Force | Out-Null
|
||||
$sources=Get-Content (Join-Path $root 'research/rules-sources.json') -Raw | ConvertFrom-Json
|
||||
foreach($source in $sources) {
|
||||
$target=Join-Path $rules $source.name
|
||||
Invoke-WebRequest $source.url -OutFile $target
|
||||
if((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ne $source.sha256) {throw "Routing rules hash mismatch: $($source.name)"}
|
||||
}
|
||||
Invoke-WebRequest 'https://github.com/SagerNet/sing-box/archive/refs/tags/v1.14.2.zip' -OutFile (Join-Path $tools 'sing-box-1.14.2-source.zip')
|
||||
Write-Output 'Pinned core, routing rules and upstream source downloaded and checked.'
|
||||
@@ -0,0 +1,16 @@
|
||||
[CmdletBinding()]
|
||||
param([switch]$RemoveData, [switch]$Silent)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installRoot = [IO.Path]::GetFullPath((Join-Path $env:ProgramFiles 'QVPN'))
|
||||
$setup = Join-Path $installRoot 'QVPN-Setup.exe'
|
||||
if (-not (Test-Path -LiteralPath $setup -PathType Leaf)) { throw 'QVPN installer was not found in Program Files.' }
|
||||
if ((Get-Item -LiteralPath $installRoot -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The QVPN directory is a reparse point.' }
|
||||
$arguments = @('--uninstall')
|
||||
if ($RemoveData) { $arguments += '--remove-data' }
|
||||
if ($Silent) { $arguments += '--silent' }
|
||||
$process = Start-Process -FilePath $setup -ArgumentList $arguments -Verb RunAs -WindowStyle Hidden -PassThru -Wait
|
||||
if ($process.ExitCode -ne 0) { throw "QVPN uninstaller exited with code $($process.ExitCode). Check the setup log in your TEMP folder." }
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(90)
|
||||
while ((Test-Path -LiteralPath $installRoot) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 500 }
|
||||
if (Test-Path -LiteralPath $installRoot) { throw 'Removal did not finish. Check QVPN-Setup-*.log in your TEMP folder.' }
|
||||
Write-Host 'QVPN removed.'
|
||||
@@ -0,0 +1,57 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$dll=Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll'
|
||||
$root=Join-Path $env:ProgramFiles 'QVPN'
|
||||
$report=Join-Path $repo 'research/installed-recovery-verification.json'
|
||||
$checks=[Collections.Generic.List[object]]::new()
|
||||
function Status {
|
||||
$raw=& dotnet $dll --client status
|
||||
if($raw -notmatch '^\{'){throw 'Status endpoint not available'}
|
||||
$response=$raw|ConvertFrom-Json
|
||||
if(!$response.Ok){throw 'Status endpoint returned failure'}
|
||||
return $response.Status
|
||||
}
|
||||
function Wait-Recovered([int]$oldEngine,[int]$oldService=0) {
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
try {
|
||||
$s=Status
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
if($s.State -eq 'connected' -and $s.DesiredConnected -and $s.EnginePid -and $s.EnginePid -ne $oldEngine -and (!$oldService -or $service.ProcessId -ne $oldService)){return $s}
|
||||
}catch{}
|
||||
}while($timer.Elapsed.TotalSeconds -lt 60)
|
||||
throw 'Automatic recovery did not complete within 60 seconds'
|
||||
}
|
||||
try {
|
||||
$s=Status
|
||||
if($s.State -ne 'connected' -or !$s.DesiredConnected){throw 'QVPN must already be connected for this test'}
|
||||
$oldEngine=$s.EnginePid
|
||||
$p=Get-Process -Id $oldEngine
|
||||
if($p.Path -ne (Join-Path $root 'core/sing-box.exe')){throw 'Unexpected engine executable'}
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew()
|
||||
Stop-Process -Id $oldEngine -Force
|
||||
$s=Wait-Recovered $oldEngine
|
||||
$checks.Add([pscustomobject]@{Scenario='Installed TUN engine crash recovery';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);Restarts=$s.Restarts;KeyEnteredAgain=$false})
|
||||
$oldEngine=$s.EnginePid
|
||||
$service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'"
|
||||
$p=Get-Process -Id $service.ProcessId
|
||||
if($p.Path -ne (Join-Path $root 'service/QVPN.Service.exe')){throw 'Unexpected service executable'}
|
||||
$oldService=$p.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 'Orphan engine survived service crash'}
|
||||
$s=Wait-Recovered $oldEngine $oldService
|
||||
$checks.Add([pscustomobject]@{Scenario='Installed SCM crash recovery and persisted intent/key';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);OrphanEngine=$false;Nodes=$s.Nodes.Count})
|
||||
$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 HTTPS check failed after recovery'}
|
||||
$checks.Add([pscustomobject]@{Scenario='System HTTPS after both crashes';Passed=$true;HttpCode=$http})
|
||||
[pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8
|
||||
}catch{
|
||||
[pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.GetType().Name+': '+$_.Exception.Message;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user