Implement QVPN Windows client for BlancVPN with resilient VPN service

This commit is contained in:
Курнат Андрей
2026-10-04 15:21:58 +03:00
commit 7e21b78b57
39 changed files with 3550 additions and 0 deletions
+89
View File
@@ -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"
+94
View File
@@ -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
}
+20
View File
@@ -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.'
+16
View File
@@ -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.'
+57
View File
@@ -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
}