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"