From a656790cd679b808013ea26eab89e043c0a9da24 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 21 May 2026 20:34:13 +0300 Subject: [PATCH] feat: add windows client installer --- README.md | 33 ++++ scripts/install-windows-client.ps1 | 296 +++++++++++++++++++++++++++++ scripts/windows/manage.ps1 | 1 + 3 files changed, 330 insertions(+) create mode 100644 scripts/install-windows-client.ps1 diff --git a/README.md b/README.md index 44f1577..7d27232 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/install-windows-client.ps1 b/scripts/install-windows-client.ps1 new file mode 100644 index 0000000..af2a4a5 --- /dev/null +++ b/scripts/install-windows-client.ps1 @@ -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" diff --git a/scripts/windows/manage.ps1 b/scripts/windows/manage.ps1 index d6c3723..4b1ddc5 100644 --- a/scripts/windows/manage.ps1 +++ b/scripts/windows/manage.ps1 @@ -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"