feat: add windows client installer

This commit is contained in:
2026-05-21 20:34:13 +03:00
parent eb688f32f6
commit a656790cd6
3 changed files with 330 additions and 0 deletions

View File

@@ -53,6 +53,39 @@ docker compose -f docker-compose.client.yml logs -f
docker compose -f docker-compose.client.yml restart
```
## Windows: app proxy client
Windows mode restores the native workflow for Discord, Vesktop, games, and other apps that do not expose proxy settings.
Run PowerShell 7 as Administrator. While this branch is being tested, install from `codex-windows-client`:
```powershell
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/codex-windows-client/scripts/install-windows-client.ps1 | iex
```
Installer modes:
- `Full install`: local native `sing-box.exe` on `127.0.0.1:1080` plus ProxiFyre/WinPacketFilter.
- `ProxiFyre only`: ProxiFyre/WinPacketFilter only, pointed at an existing SOCKS5 proxy such as `127.0.0.1:8080` or `192.168.50.111:8080`.
The installer keeps profile data under `C:\Tools\vpn-proxy-windows\data`, so rerunning it can replace app files without deleting saved profiles.
Local UI:
```text
http://127.0.0.1:3456
```
Recovery commands:
```powershell
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -OpenUi
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -Status
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -RestartServices
```
The UI manages profiles made of process names, folders, and explicit `.exe` files. It generates ProxiFyre config and restarts ProxiFyre only when the user applies changes.
---
# VPN Proxy Gateway

View File

@@ -0,0 +1,296 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT
if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = "C:\Tools\vpn-proxy-windows" }
$RepoBranch = $env:VPN_PROXY_WINDOWS_BRANCH
if ([string]::IsNullOrWhiteSpace($RepoBranch)) { $RepoBranch = "codex-windows-client" }
$AppDir = Join-Path $InstallRoot "app"
$DataDir = Join-Path $InstallRoot "data"
$RuntimeDir = Join-Path $InstallRoot "runtime"
$NodeDir = Join-Path $RuntimeDir "node"
$SingBoxDir = Join-Path $RuntimeDir "sing-box"
$ProxiFyreRoot = $env:PROXIFYRE_ROOT
if ([string]::IsNullOrWhiteSpace($ProxiFyreRoot)) { $ProxiFyreRoot = "C:\Tools\ProxiFyre" }
$RepoZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/$RepoBranch.zip"
$SingBoxVersion = "1.12.13"
$SingBoxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingBoxVersion/sing-box-$SingBoxVersion-windows-amd64.zip"
$Headers = @{ "User-Agent" = "vpn-proxy-windows-installer" }
function Assert-Admin {
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "Run PowerShell 7 as Administrator"
}
}
function Assert-PowerShell7 {
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw "PowerShell 7 is required"
}
}
function Get-Arch {
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { return "arm64" }
if ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { return "x64" }
return "x86"
}
function Download-File {
param([string]$Url, [string]$Destination)
Write-Host "Downloading $Url"
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -Headers $Headers
Unblock-File -Path $Destination -ErrorAction SilentlyContinue
}
function Invoke-CheckedProcess {
param(
[string]$FilePath,
[string[]]$ArgumentList,
[int[]]$AllowedExitCodes = @(0)
)
$process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru
if ($AllowedExitCodes -notcontains $process.ExitCode) {
throw "$FilePath failed with exit code $($process.ExitCode)"
}
return $process.ExitCode
}
function Get-GitHubReleaseAsset {
param(
[string]$Repo,
[scriptblock]$AssetFilter
)
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $Headers
$asset = @($release.assets | Where-Object $AssetFilter | Select-Object -First 1)
if (-not $asset) {
throw "No matching release asset found for $Repo"
}
return $asset[0]
}
function Install-AppFiles {
New-Item -ItemType Directory -Force -Path $InstallRoot, $DataDir, $RuntimeDir | Out-Null
$zip = Join-Path $env:TEMP "vpn-proxy-windows.zip"
$extract = Join-Path $env:TEMP "vpn-proxy-windows-extract"
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
Download-File -Url $RepoZipUrl -Destination $zip
Expand-Archive -Path $zip -DestinationPath $extract -Force
$source = Get-ChildItem $extract -Directory | Select-Object -First 1
if (-not $source) { throw "Downloaded archive layout is not recognized" }
if (Test-Path $AppDir) {
$backup = "$AppDir.backup"
Remove-Item $backup -Recurse -Force -ErrorAction SilentlyContinue
Move-Item $AppDir $backup
}
Move-Item $source.FullName $AppDir
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
}
function Install-NodeRuntime {
$existing = Get-Command node -ErrorAction SilentlyContinue
if ($existing) { return $existing.Source }
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
$arch = Get-Arch
$index = Invoke-RestMethod -Uri "https://nodejs.org/dist/index.json" -Headers $Headers
$release = @($index | Where-Object { $_.lts -ne $false } | Select-Object -First 1)[0]
if (-not $release) { throw "Cannot resolve latest Node.js LTS release" }
$version = [string]$release.version
$assetName = "node-$version-win-$arch.zip"
$zip = Join-Path $env:TEMP $assetName
$extract = Join-Path $env:TEMP "node-windows-extract"
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $NodeDir -Recurse -Force -ErrorAction SilentlyContinue
Download-File -Url "https://nodejs.org/dist/$version/$assetName" -Destination $zip
Expand-Archive -Path $zip -DestinationPath $extract -Force
$nodeSource = Get-ChildItem $extract -Directory | Select-Object -First 1
if (-not $nodeSource) { throw "Downloaded Node.js archive layout is not recognized" }
Move-Item $nodeSource.FullName $NodeDir
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
return (Join-Path $NodeDir "node.exe")
}
function Get-NpmCommand {
param([string]$NodeCommand)
$portableNpm = Join-Path (Split-Path -Parent $NodeCommand) "npm.cmd"
if (Test-Path $portableNpm) { return $portableNpm }
$existing = Get-Command npm -ErrorAction SilentlyContinue
if ($existing) { return $existing.Source }
throw "npm was not found"
}
function Install-VisualCRedistributable {
$arch = Get-Arch
$vcArch = if ($arch -eq "arm64") { "arm64" } elseif ($arch -eq "x86") { "x86" } else { "x64" }
$exe = Join-Path $env:TEMP "vc_redist.$vcArch.exe"
Download-File -Url "https://aka.ms/vs/17/release/vc_redist.$vcArch.exe" -Destination $exe
$code = Invoke-CheckedProcess -FilePath $exe -ArgumentList @("/install", "/quiet", "/norestart") -AllowedExitCodes @(0, 3010)
if ($code -eq 3010) {
Write-Warning "Visual C++ Redistributable requested a reboot"
}
}
function Install-WinPacketFilter {
$service = Get-Service -Name "ndisrd" -ErrorAction SilentlyContinue
if ($service -and $service.Status -eq "Running") {
Write-Host "WinPacketFilter driver is already running"
return
}
$arch = Get-Arch
$assetToken = if ($arch -eq "arm64") { "ARM64" } elseif ($arch -eq "x86") { "x86" } else { "x64" }
$asset = Get-GitHubReleaseAsset -Repo "wiresock/ndisapi" -AssetFilter {
param($item)
$item.name -match "\.msi$" -and $item.name -match $assetToken
}
$msi = Join-Path $env:TEMP $asset.name
Download-File -Url $asset.browser_download_url -Destination $msi
$code = Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/i", "`"$msi`"", "/qn", "/norestart") -AllowedExitCodes @(0, 3010)
if ($code -eq 3010) {
Write-Warning "WinPacketFilter requested a reboot before first use"
}
}
function Install-ProxiFyre {
New-Item -ItemType Directory -Force -Path $ProxiFyreRoot | Out-Null
if (Test-Path (Join-Path $ProxiFyreRoot "ProxiFyre.exe")) {
Write-Host "ProxiFyre is already installed at $ProxiFyreRoot"
return
}
$asset = Get-GitHubReleaseAsset -Repo "wiresock/proxifyre" -AssetFilter {
param($item)
$item.name -match "\.zip$" -and $item.name -notmatch "source"
}
$zip = Join-Path $env:TEMP $asset.name
$extract = Join-Path $env:TEMP "proxifyre-extract"
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
Download-File -Url $asset.browser_download_url -Destination $zip
Expand-Archive -Path $zip -DestinationPath $extract -Force
$exe = Get-ChildItem $extract -Recurse -Filter "ProxiFyre.exe" | Select-Object -First 1
if (-not $exe) { throw "ProxiFyre.exe was not found in release archive" }
Copy-Item (Join-Path (Split-Path -Parent $exe.FullName) "*") $ProxiFyreRoot -Recurse -Force
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
}
function Install-SingBox {
New-Item -ItemType Directory -Force -Path $SingBoxDir | Out-Null
if (Test-Path (Join-Path $SingBoxDir "sing-box.exe")) { return }
$zip = Join-Path $env:TEMP "sing-box-windows.zip"
$extract = Join-Path $env:TEMP "sing-box-windows-extract"
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
Download-File -Url $SingBoxUrl -Destination $zip
Expand-Archive -Path $zip -DestinationPath $extract -Force
$exe = Get-ChildItem $extract -Recurse -Filter "sing-box.exe" | Select-Object -First 1
if (-not $exe) { throw "sing-box.exe was not found in archive" }
Copy-Item $exe.FullName (Join-Path $SingBoxDir "sing-box.exe") -Force
}
function Select-InstallMode {
Write-Host ""
Write-Host "Choose install mode:"
Write-Host " [1] Full install: local sing-box + ProxiFyre"
Write-Host " [2] ProxiFyre only: use existing proxy target"
$choice = Read-Host "Mode [1]"
if ($choice -eq "2") { return "proxifyre-only" }
return "full"
}
function Test-TcpEndpoint {
param([string]$HostName, [int]$Port)
$client = [System.Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync($HostName, $Port)
if (-not $task.Wait(2000)) { return $false }
return $client.Connected
} finally {
$client.Dispose()
}
}
function Write-InitialTargets {
param([string]$Mode)
$targetsPath = Join-Path $DataDir "proxy-targets.json"
if (Test-Path $targetsPath) { return }
if ($Mode -eq "proxifyre-only") {
$target = Read-Host "Existing SOCKS5 proxy target host:port"
if ($target -notmatch "^([^:]+):(\d+)$") { throw "Expected host:port" }
$hostName = $matches[1]
$port = [int]$matches[2]
if (-not (Test-TcpEndpoint -HostName $hostName -Port $port)) {
Write-Warning "Proxy target $target did not accept a TCP connection during install"
}
@(@{ id = "existing-proxy"; name = "Existing proxy"; protocol = "socks5"; host = $hostName; port = $port }) |
ConvertTo-Json -Depth 5 |
Set-Content $targetsPath -Encoding UTF8
}
}
function Install-NodeDependencies {
$node = Install-NodeRuntime
$npm = Get-NpmCommand -NodeCommand $node
$env:PATH = "$(Split-Path -Parent $node);$env:PATH"
Push-Location $AppDir
try {
& $npm install
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
& $npm run build
if ($LASTEXITCODE -ne 0) { throw "npm run build failed" }
} finally {
Pop-Location
}
}
function Start-Ui {
$manage = Join-Path $AppDir "scripts\windows\manage.ps1"
Start-Process pwsh -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$manage`"", "-OpenUi"
}
Assert-Admin
Assert-PowerShell7
$mode = Select-InstallMode
Install-AppFiles
Install-NodeDependencies
Install-VisualCRedistributable
Install-WinPacketFilter
Install-ProxiFyre
if ($mode -eq "full") { Install-SingBox }
Write-InitialTargets -Mode $mode
Set-Content -Path (Join-Path $DataDir "windows-state.json") -Encoding UTF8 -Value (@{ installMode = $mode } | ConvertTo-Json)
Start-Ui
Write-Host ""
Write-Host "VPN Proxy Windows is installed."
Write-Host "UI: http://127.0.0.1:3456"
Write-Host "Recovery:"
Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -OpenUi"
Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -Status"
Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -RestartServices"

View File

@@ -31,6 +31,7 @@ $env:PROXY_BIND_IP = "127.0.0.1"
$env:SING_BOX_CONFIG = Join-Path $Root "runtime\sing-box\config.json"
$env:SING_BOX_CACHE = Join-Path $Root "runtime\sing-box\cache.db"
$env:WINDOWS_HELPER = Join-Path $AppDir "scripts\windows\helper.ps1"
$env:PATH = "$(Join-Path $Root "runtime\sing-box");$env:PATH"
function Get-NodeCommand {
$portable = Join-Path $Root "runtime\node\node.exe"