Compare commits
8 Commits
master
...
codex-wind
| Author | SHA1 | Date | |
|---|---|---|---|
| a656790cd6 | |||
| eb688f32f6 | |||
| 6e0d97b65b | |||
| 71e628fbde | |||
| f7e8138ab1 | |||
| 39eca49f62 | |||
| 68158f3907 | |||
| 12ad0c8b78 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ _archive/
|
||||
data/
|
||||
.vpn-proxy/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
|
||||
# Node/Vite
|
||||
node_modules/
|
||||
|
||||
33
README.md
33
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
|
||||
|
||||
296
scripts/install-windows-client.ps1
Normal file
296
scripts/install-windows-client.ps1
Normal 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"
|
||||
154
scripts/windows/VpnProxy.Windows.psm1
Normal file
154
scripts/windows/VpnProxy.Windows.psm1
Normal file
@@ -0,0 +1,154 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$script:InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($script:InstallRoot)) {
|
||||
$script:InstallRoot = "C:\Tools\vpn-proxy-windows"
|
||||
}
|
||||
|
||||
$script:ProxiFyreRoot = $env:PROXIFYRE_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($script:ProxiFyreRoot)) {
|
||||
$script:ProxiFyreRoot = "C:\Tools\ProxiFyre"
|
||||
}
|
||||
|
||||
function New-VpnProxyResult {
|
||||
param(
|
||||
[string]$Action,
|
||||
[bool]$Success,
|
||||
[object]$Result = $null,
|
||||
[string]$Message = "",
|
||||
[string]$ErrorMessage = ""
|
||||
)
|
||||
|
||||
$value = [ordered]@{
|
||||
success = $Success
|
||||
action = $Action
|
||||
}
|
||||
if ($null -ne $Result) { $value.result = $Result }
|
||||
if ($Message) { $value.message = $Message }
|
||||
if ($ErrorMessage) { $value.error = $ErrorMessage }
|
||||
return $value
|
||||
}
|
||||
|
||||
function Get-VpnProxyStatus {
|
||||
$task = Get-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue
|
||||
$singboxProcess = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue
|
||||
$proxifyre = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
||||
|
||||
return [ordered]@{
|
||||
singbox = if ($singboxProcess) { "Running" } elseif ($task) { [string]$task.State } else { "NotInstalled" }
|
||||
proxifyre = if ($proxifyre) { [string]$proxifyre.Status } else { "NotInstalled" }
|
||||
installRoot = $script:InstallRoot
|
||||
proxifyreRoot = $script:ProxiFyreRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ProxiFyreConfig {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ConfigPath,
|
||||
[Parameter(Mandatory=$true)][object]$Config
|
||||
)
|
||||
|
||||
$dir = Split-Path -Parent $ConfigPath
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
if (Test-Path $ConfigPath) {
|
||||
Copy-Item $ConfigPath "$ConfigPath.bak" -Force
|
||||
}
|
||||
$Config | ConvertTo-Json -Depth 20 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Restart-ProxiFyre {
|
||||
$exe = Join-Path $script:ProxiFyreRoot "ProxiFyre.exe"
|
||||
if (-not (Test-Path $exe)) {
|
||||
throw "ProxiFyre.exe not found at $exe"
|
||||
}
|
||||
|
||||
& $exe stop 2>$null | Out-Null
|
||||
& $exe install 2>$null | Out-Null
|
||||
& $exe start 2>$null | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-ProxiFyreApply {
|
||||
param([object]$Payload)
|
||||
|
||||
Write-ProxiFyreConfig -ConfigPath $Payload.configPath -Config $Payload.config
|
||||
Restart-ProxiFyre
|
||||
return New-VpnProxyResult -Action "proxifyre.apply" -Success $true -Message "ProxiFyre config applied and service restarted"
|
||||
}
|
||||
|
||||
function Invoke-ServiceControl {
|
||||
param([object]$Payload)
|
||||
|
||||
$service = [string]$Payload.service
|
||||
$action = [string]$Payload.action
|
||||
|
||||
if ($service -eq "proxifyre") {
|
||||
if ($action -eq "restart") { Restart-ProxiFyre }
|
||||
elseif ($action -eq "start") { Start-Service -Name "ProxiFyreService" }
|
||||
elseif ($action -eq "stop") { Stop-Service -Name "ProxiFyreService" -Force }
|
||||
else { throw "Unknown ProxiFyre action: $action" }
|
||||
} elseif ($service -eq "sing-box") {
|
||||
if ($action -eq "restart") {
|
||||
Stop-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue
|
||||
Start-ScheduledTask -TaskName "SingBoxProxy"
|
||||
} elseif ($action -eq "start") {
|
||||
Start-ScheduledTask -TaskName "SingBoxProxy"
|
||||
} elseif ($action -eq "stop") {
|
||||
Stop-ScheduledTask -TaskName "SingBoxProxy"
|
||||
} else {
|
||||
throw "Unknown sing-box action: $action"
|
||||
}
|
||||
} elseif ($service -eq "ui") {
|
||||
return New-VpnProxyResult -Action "service.control" -Success $true -Message "UI is controlled by manage.ps1 -OpenUi"
|
||||
} else {
|
||||
throw "Unknown service: $service"
|
||||
}
|
||||
|
||||
return New-VpnProxyResult -Action "service.control" -Success $true -Message "$service $action complete"
|
||||
}
|
||||
|
||||
function Get-VpnProxyLogs {
|
||||
$paths = @(
|
||||
(Join-Path $script:InstallRoot "runtime\sing-box\singbox.log"),
|
||||
(Join-Path $script:ProxiFyreRoot "ProxiFyre.log")
|
||||
)
|
||||
$logs = @()
|
||||
|
||||
foreach ($path in $paths) {
|
||||
if (Test-Path $path) {
|
||||
$logs += [ordered]@{
|
||||
path = $path
|
||||
lines = @(Get-Content $path -Tail 120 -ErrorAction SilentlyContinue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $logs
|
||||
}
|
||||
|
||||
function Invoke-VpnProxyAction {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Action,
|
||||
[object]$Payload = @{}
|
||||
)
|
||||
|
||||
switch ($Action) {
|
||||
"status.get" {
|
||||
return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyStatus)
|
||||
}
|
||||
"proxifyre.apply" {
|
||||
return Invoke-ProxiFyreApply -Payload $Payload
|
||||
}
|
||||
"service.control" {
|
||||
return Invoke-ServiceControl -Payload $Payload
|
||||
}
|
||||
"logs.get" {
|
||||
return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyLogs)
|
||||
}
|
||||
default {
|
||||
throw "Unknown action: $Action"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-VpnProxyAction, Get-VpnProxyStatus
|
||||
26
scripts/windows/helper.ps1
Normal file
26
scripts/windows/helper.ps1
Normal file
@@ -0,0 +1,26 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Import-Module (Join-Path $ScriptDir "VpnProxy.Windows.psm1") -Force
|
||||
|
||||
try {
|
||||
$raw = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
throw "Missing JSON input"
|
||||
}
|
||||
|
||||
$request = $raw | ConvertFrom-Json
|
||||
$payload = if ($request.PSObject.Properties.Name -contains "payload") { $request.payload } else { @{} }
|
||||
$result = Invoke-VpnProxyAction -Action ([string]$request.action) -Payload $payload
|
||||
$result | ConvertTo-Json -Depth 30 -Compress
|
||||
exit 0
|
||||
} catch {
|
||||
$errorResult = [ordered]@{
|
||||
success = $false
|
||||
action = "error"
|
||||
error = $_.Exception.Message
|
||||
}
|
||||
$errorResult | ConvertTo-Json -Depth 10 -Compress
|
||||
exit 1
|
||||
}
|
||||
65
scripts/windows/manage.ps1
Normal file
65
scripts/windows/manage.ps1
Normal file
@@ -0,0 +1,65 @@
|
||||
param(
|
||||
[switch]$OpenUi,
|
||||
[switch]$Status,
|
||||
[switch]$RestartServices
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AppDir = Split-Path -Parent (Split-Path -Parent $ScriptDir)
|
||||
if (-not (Test-Path (Join-Path $AppDir "package.json"))) {
|
||||
throw "Cannot locate app root from $ScriptDir"
|
||||
}
|
||||
|
||||
$Root = $env:VPN_PROXY_WINDOWS_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($Root)) {
|
||||
if ((Split-Path -Leaf $AppDir) -eq "app") {
|
||||
$Root = Split-Path -Parent $AppDir
|
||||
} else {
|
||||
$Root = $AppDir
|
||||
}
|
||||
}
|
||||
|
||||
$env:VPN_PROXY_WINDOWS_ROOT = $Root
|
||||
$env:APP_MODE = "windows"
|
||||
$env:DATA_DIR = Join-Path $Root "data"
|
||||
$env:DIST_DIR = Join-Path $AppDir "dist"
|
||||
$env:PROXY_PORT = "1080"
|
||||
$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"
|
||||
if (Test-Path $portable) { return $portable }
|
||||
return "node"
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
$inputJson = @{ action = "status.get"; payload = @{} } | ConvertTo-Json -Compress
|
||||
$inputJson | & $env:WINDOWS_HELPER
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($RestartServices) {
|
||||
$helper = $env:WINDOWS_HELPER
|
||||
(@{ action = "service.control"; payload = @{ service = "proxifyre"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
|
||||
(@{ action = "service.control"; payload = @{ service = "sing-box"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($OpenUi) {
|
||||
$node = Get-NodeCommand
|
||||
Start-Process "http://127.0.0.1:3456"
|
||||
& $node (Join-Path $AppDir "src\server\index.js")
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "VPN Proxy Windows"
|
||||
Write-Host " -OpenUi Start local UI"
|
||||
Write-Host " -Status Print JSON status"
|
||||
Write-Host " -RestartServices Restart ProxiFyre and sing-box"
|
||||
@@ -1,9 +1,13 @@
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
const rawAppMode = String(process.env.APP_MODE || "gateway").toLowerCase();
|
||||
const appMode = ["gateway", "client", "windows"].includes(rawAppMode)
|
||||
? rawAppMode
|
||||
: "gateway";
|
||||
|
||||
export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
appMode,
|
||||
port: Number(process.env.PORT || 3456),
|
||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||
@@ -22,10 +26,24 @@ export const settings = {
|
||||
devicesPath: path.join(dataDir, "devices.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
windowsProfilesPath: path.join(dataDir, "windows-profiles.json"),
|
||||
windowsTargetsPath: path.join(dataDir, "proxy-targets.json"),
|
||||
windowsStatePath: path.join(dataDir, "windows-state.json"),
|
||||
windowsActivityPath: path.join(dataDir, "windows-activity.json"),
|
||||
windowsHelperPath:
|
||||
process.env.WINDOWS_HELPER || path.resolve("scripts/windows/helper.ps1"),
|
||||
proxifyreConfigPath:
|
||||
process.env.PROXIFYRE_CONFIG ||
|
||||
"C:\\Tools\\ProxiFyre\\app-config.json",
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
appName:
|
||||
appMode === "windows"
|
||||
? "VPN Proxy Windows"
|
||||
: appMode === "client"
|
||||
? "VPN Proxy Client"
|
||||
: "VPN Proxy Gateway",
|
||||
};
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
} from "./sharedProxy.js";
|
||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||
import { tcpPing, resolveHost } from "./ping.js";
|
||||
import {
|
||||
buildProxiFyreConfig,
|
||||
createActivityEntry,
|
||||
normalizeProxyTargets,
|
||||
normalizeWindowsProfiles,
|
||||
summarizeProfiles,
|
||||
} from "./windowsProfiles.js";
|
||||
import { windowsHelper } from "./windowsHelper.js";
|
||||
|
||||
const APPLY_HISTORY_LIMIT = 10;
|
||||
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
||||
@@ -603,6 +611,8 @@ function publicState() {
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const clientSettings = readClientSettings();
|
||||
const windowsTargets =
|
||||
settings.appMode === "windows" ? readProxyTargets() : [];
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
@@ -634,6 +644,17 @@ function publicState() {
|
||||
directBypassCount,
|
||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||
directBypassAvailable: IPSET_AVAILABLE,
|
||||
windows:
|
||||
settings.appMode === "windows"
|
||||
? {
|
||||
profiles: summarizeProfiles(
|
||||
readWindowsProfiles(),
|
||||
windowsTargets,
|
||||
),
|
||||
targets: windowsTargets,
|
||||
activity: readWindowsActivity().slice(-20).reverse(),
|
||||
}
|
||||
: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
@@ -686,6 +707,62 @@ function normalizeDeviceRules(input) {
|
||||
}));
|
||||
}
|
||||
|
||||
function readWindowsProfiles() {
|
||||
return normalizeWindowsProfiles(readJson(settings.windowsProfilesPath, []));
|
||||
}
|
||||
|
||||
function writeWindowsProfiles(profiles) {
|
||||
const normalized = normalizeWindowsProfiles(profiles);
|
||||
writeJson(settings.windowsProfilesPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readProxyTargets() {
|
||||
return normalizeProxyTargets(readJson(settings.windowsTargetsPath, []));
|
||||
}
|
||||
|
||||
function writeProxyTargets(targets) {
|
||||
const normalized = normalizeProxyTargets(targets);
|
||||
writeJson(
|
||||
settings.windowsTargetsPath,
|
||||
normalized.filter((target) => !target.managed),
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readWindowsActivity() {
|
||||
return readJson(settings.windowsActivityPath, []).slice(-100);
|
||||
}
|
||||
|
||||
function pushWindowsActivity(type, message, details = {}) {
|
||||
const activity = readWindowsActivity();
|
||||
const entry = createActivityEntry(type, message, details);
|
||||
writeJson(settings.windowsActivityPath, [...activity, entry].slice(-100));
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function getWindowsStatus() {
|
||||
let helperStatus = null;
|
||||
if (settings.appMode === "windows") {
|
||||
try {
|
||||
helperStatus = await windowsHelper.run("status.get", {});
|
||||
} catch (error) {
|
||||
helperStatus = { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
installMode:
|
||||
readJson(settings.windowsStatePath, {}).installMode || "not-configured",
|
||||
profiles: summarizeProfiles(profiles, targets),
|
||||
targets,
|
||||
activity: readWindowsActivity().slice(-20).reverse(),
|
||||
helperStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) {
|
||||
@@ -809,6 +886,99 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, { success: true, config });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/status") {
|
||||
return sendJson(res, 200, { success: true, ...(await getWindowsStatus()) });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/profiles") {
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
profiles,
|
||||
summaries: summarizeProfiles(profiles, targets),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && req.url === "/api/windows/profiles") {
|
||||
const body = await readBody(req);
|
||||
const profiles = writeWindowsProfiles(body.profiles || []);
|
||||
pushWindowsActivity("profiles.saved", "Profiles saved", {
|
||||
count: profiles.length,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
profiles,
|
||||
summaries: summarizeProfiles(profiles, readProxyTargets()),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/profiles/scan") {
|
||||
const body = await readBody(req);
|
||||
const profiles = normalizeWindowsProfiles(body.profiles || []);
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
summaries: summarizeProfiles(profiles, readProxyTargets()),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/profiles/apply") {
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
const proxifyreConfig = buildProxiFyreConfig(profiles, targets);
|
||||
const helperResult = await windowsHelper.run("proxifyre.apply", {
|
||||
configPath: settings.proxifyreConfigPath,
|
||||
config: proxifyreConfig,
|
||||
});
|
||||
pushWindowsActivity("profiles.applied", "ProxiFyre config applied", {
|
||||
proxyGroups: proxifyreConfig.proxies.length,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
config: proxifyreConfig,
|
||||
helperResult,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/targets") {
|
||||
return sendJson(res, 200, { success: true, targets: readProxyTargets() });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && req.url === "/api/windows/targets") {
|
||||
const body = await readBody(req);
|
||||
const targets = writeProxyTargets(body.targets || []);
|
||||
pushWindowsActivity("targets.saved", "Proxy targets saved", {
|
||||
count: targets.length,
|
||||
});
|
||||
return sendJson(res, 200, { success: true, targets });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/service") {
|
||||
const body = await readBody(req);
|
||||
const service = String(body.service || "");
|
||||
const action = String(body.action || "");
|
||||
if (!["sing-box", "proxifyre", "ui"].includes(service)) {
|
||||
return sendJson(res, 400, { success: false, error: "Unknown service" });
|
||||
}
|
||||
if (!["start", "stop", "restart"].includes(action)) {
|
||||
return sendJson(res, 400, { success: false, error: "Unknown action" });
|
||||
}
|
||||
const helperResult = await windowsHelper.run("service.control", {
|
||||
service,
|
||||
action,
|
||||
});
|
||||
pushWindowsActivity("service.control", `${service} ${action}`, {
|
||||
service,
|
||||
action,
|
||||
});
|
||||
return sendJson(res, 200, { success: true, helperResult });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/logs") {
|
||||
const helperResult = await windowsHelper.run("logs.get", {});
|
||||
return sendJson(res, 200, { success: true, helperResult });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/logs") {
|
||||
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
|
||||
}
|
||||
|
||||
@@ -267,6 +267,8 @@ export function buildGatewayConfig(
|
||||
{ bypassAll = false } = {},
|
||||
) {
|
||||
const customRuleSets = readCustomRuleSets();
|
||||
const proxyOnlyMode =
|
||||
settings.appMode === "client" || settings.appMode === "windows";
|
||||
const clientMode = settings.appMode === "client";
|
||||
const clientSettings = clientMode ? readClientSettings() : null;
|
||||
const sharedOutbound =
|
||||
@@ -295,7 +297,7 @@ export function buildGatewayConfig(
|
||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||
const inbounds = [
|
||||
...(clientMode
|
||||
...(proxyOnlyMode
|
||||
? []
|
||||
: [
|
||||
{
|
||||
@@ -338,13 +340,16 @@ export function buildGatewayConfig(
|
||||
{ type: "block", tag: "block" },
|
||||
],
|
||||
route: {
|
||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rule_set:
|
||||
bypassAll || proxyOnlyMode
|
||||
? []
|
||||
: ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rules: bypassAll
|
||||
? [{ ip_is_private: true, outbound: "direct" }]
|
||||
: clientMode
|
||||
: proxyOnlyMode
|
||||
? proxyOnlyRules
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||
includeTransparent: !clientMode,
|
||||
includeTransparent: !proxyOnlyMode,
|
||||
}),
|
||||
final: "direct",
|
||||
auto_detect_interface: true,
|
||||
|
||||
54
src/server/windowsHelper.js
Normal file
54
src/server/windowsHelper.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
function defaultRunner(command, args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
resolve({ status: 1, stdout, stderr: error.message });
|
||||
});
|
||||
child.on("close", (status) => {
|
||||
resolve({ status, stdout, stderr });
|
||||
});
|
||||
child.stdin.end(options.input || "");
|
||||
});
|
||||
}
|
||||
|
||||
export function createWindowsHelper(options = {}) {
|
||||
const helperPath = options.helperPath || settings.windowsHelperPath;
|
||||
const command = options.command || "pwsh";
|
||||
const runner = options.runner || defaultRunner;
|
||||
return {
|
||||
async run(action, payload = {}) {
|
||||
const input = JSON.stringify({ action, payload });
|
||||
const result = await runner(
|
||||
command,
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", helperPath],
|
||||
{ input },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Windows helper failed: ${(result.stderr || result.stdout || "helper exited without stderr").trim()}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error(`Windows helper returned invalid JSON: ${result.stdout}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const windowsHelper = createWindowsHelper();
|
||||
210
src/server/windowsProfiles.js
Normal file
210
src/server/windowsProfiles.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ITEM_TYPES = new Set(["process", "folder", "exe"]);
|
||||
const PROTOCOLS = new Set(["TCP", "UDP"]);
|
||||
|
||||
function slug(value, fallback) {
|
||||
const cleaned = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function cleanString(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function processName(value) {
|
||||
const base = cleanString(value).split(/[\\/]/).pop() || "";
|
||||
return base.replace(/\.exe$/i, "").trim();
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function normalizeWindowsProfiles(input) {
|
||||
return (Array.isArray(input) ? input : [])
|
||||
.map((profile, index) => {
|
||||
const name = cleanString(profile.name) || `Profile ${index + 1}`;
|
||||
const items = (Array.isArray(profile.items) ? profile.items : [])
|
||||
.filter((item) => ITEM_TYPES.has(item?.type))
|
||||
.map((item) => ({
|
||||
type: item.type,
|
||||
value:
|
||||
item.type === "process"
|
||||
? processName(item.value)
|
||||
: cleanString(item.value),
|
||||
recursive: item.type === "folder" ? item.recursive !== false : false,
|
||||
}))
|
||||
.filter((item) => item.value);
|
||||
|
||||
return {
|
||||
id: slug(profile.id || name, `profile-${index + 1}`),
|
||||
name,
|
||||
enabled: profile.enabled !== false,
|
||||
proxyTargetId: cleanString(profile.proxyTargetId) || "local-singbox",
|
||||
protocols: unique(
|
||||
(Array.isArray(profile.protocols)
|
||||
? profile.protocols
|
||||
: ["TCP", "UDP"])
|
||||
.map((protocol) => cleanString(protocol).toUpperCase())
|
||||
.filter((protocol) => PROTOCOLS.has(protocol)),
|
||||
),
|
||||
items,
|
||||
};
|
||||
})
|
||||
.map((profile) => ({
|
||||
...profile,
|
||||
protocols: profile.protocols.length ? profile.protocols : ["TCP", "UDP"],
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeProxyTargets(input) {
|
||||
const local = {
|
||||
id: "local-singbox",
|
||||
name: "Local sing-box",
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
managed: true,
|
||||
};
|
||||
const seen = new Set([local.id]);
|
||||
const custom = (Array.isArray(input) ? input : [])
|
||||
.map((target, index) => ({
|
||||
id: slug(target.id || target.name, `target-${index + 1}`),
|
||||
name: cleanString(target.name) || `Proxy target ${index + 1}`,
|
||||
protocol:
|
||||
cleanString(target.protocol || "socks5").toLowerCase() === "http"
|
||||
? "http"
|
||||
: "socks5",
|
||||
host: cleanString(target.host),
|
||||
port: Number.parseInt(target.port, 10),
|
||||
managed: false,
|
||||
}))
|
||||
.filter((target) => {
|
||||
if (!target.host || !Number.isInteger(target.port)) return false;
|
||||
if (target.port <= 0 || target.port > 65535) return false;
|
||||
if (seen.has(target.id)) return false;
|
||||
seen.add(target.id);
|
||||
return true;
|
||||
});
|
||||
return [local, ...custom];
|
||||
}
|
||||
|
||||
function joinPath(base, name, pathSep) {
|
||||
return base.endsWith(pathSep) ? `${base}${name}` : `${base}${pathSep}${name}`;
|
||||
}
|
||||
|
||||
function walkExeFiles(dir, { fsAdapter, recursive, pathSep }) {
|
||||
const entries = fsAdapter.readdirSync(dir, { withFileTypes: true });
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinPath(dir, entry.name, pathSep);
|
||||
if (entry.isFile() && /\.exe$/i.test(entry.name)) results.push(fullPath);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
results.push(...walkExeFiles(fullPath, { fsAdapter, recursive, pathSep }));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function resolveProfileItems(items, options = {}) {
|
||||
const fsAdapter = options.fsAdapter || fs;
|
||||
const pathSep = options.pathSep || path.sep;
|
||||
const resolved = [];
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (item.type === "process") {
|
||||
const appName = processName(item.value);
|
||||
if (appName) resolved.push({ ...item, appName, source: item.value });
|
||||
}
|
||||
if (item.type === "exe") {
|
||||
const appName = processName(item.value);
|
||||
if (appName) resolved.push({ ...item, appName, source: item.value });
|
||||
}
|
||||
if (item.type === "folder" && fsAdapter.existsSync(item.value)) {
|
||||
const stat = fsAdapter.statSync(item.value);
|
||||
if (stat.isDirectory()) {
|
||||
for (const filePath of walkExeFiles(item.value, {
|
||||
fsAdapter,
|
||||
recursive: item.recursive !== false,
|
||||
pathSep,
|
||||
})) {
|
||||
resolved.push({
|
||||
...item,
|
||||
appName: processName(filePath),
|
||||
source: filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const byName = new Map();
|
||||
for (const item of resolved) {
|
||||
if (!byName.has(item.appName)) byName.set(item.appName, item);
|
||||
}
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
export function buildProxiFyreConfig(profiles, targets, options = {}) {
|
||||
const normalizedTargets = normalizeProxyTargets(targets);
|
||||
const targetById = new Map(
|
||||
normalizedTargets.map((target) => [target.id, target]),
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const profile of normalizeWindowsProfiles(profiles).filter(
|
||||
(item) => item.enabled,
|
||||
)) {
|
||||
const target =
|
||||
targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
|
||||
const resolved = resolveProfileItems(profile.items, options);
|
||||
if (!target || resolved.length === 0) continue;
|
||||
const key = `${target.id}|${profile.protocols.join(",")}`;
|
||||
const existing = groups.get(key) || {
|
||||
appNames: [],
|
||||
socks5ProxyEndpoint: `${target.host}:${target.port}`,
|
||||
supportedProtocols: profile.protocols,
|
||||
};
|
||||
existing.appNames.push(...resolved.map((item) => item.appName));
|
||||
existing.appNames = unique(existing.appNames).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
return {
|
||||
logLevel: "Info",
|
||||
proxies: Array.from(groups.values()),
|
||||
excludes: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeProfiles(profiles, targets, options = {}) {
|
||||
const normalizedTargets = normalizeProxyTargets(targets);
|
||||
const targetById = new Map(
|
||||
normalizedTargets.map((target) => [target.id, target]),
|
||||
);
|
||||
return normalizeWindowsProfiles(profiles).map((profile) => {
|
||||
const resolvedItems = resolveProfileItems(profile.items, options);
|
||||
const target =
|
||||
targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
|
||||
return {
|
||||
...profile,
|
||||
target,
|
||||
resolvedCount: resolvedItems.length,
|
||||
resolvedItems,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createActivityEntry(type, message, details = {}) {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
ts: new Date().toISOString(),
|
||||
type,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { StatusPane } from './components/StatusPane.jsx';
|
||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
|
||||
import { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
@@ -97,6 +98,9 @@ function App() {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
if (state?.mode === 'windows' && (page === 'servers' || page === 'routing')) {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -381,6 +385,7 @@ function App() {
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
const isWindowsMode = state?.mode === 'windows';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
@@ -409,11 +414,14 @@ function App() {
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}${isWindowsMode ? ' windows-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
{page === 'overview' && isWindowsMode && (
|
||||
<WindowsOverviewPage pushToast={pushToast} />
|
||||
)}
|
||||
{(page === 'overview' || isClientMode) && !isWindowsMode && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
@@ -447,7 +455,7 @@ function App() {
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && !isClientMode && (
|
||||
{page === 'servers' && !isClientMode && !isWindowsMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
@@ -463,7 +471,7 @@ function App() {
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && !isClientMode && (
|
||||
{page === 'routing' && !isClientMode && !isWindowsMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
@@ -497,7 +505,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
{(page === 'routing' && dirtyRouting && !isWindowsMode) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
@@ -522,7 +530,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(page === 'servers' && dirtyServer) && (
|
||||
{(page === 'servers' && dirtyServer && !isWindowsMode) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className="dot warning" />
|
||||
@@ -539,7 +547,7 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isClientMode && (
|
||||
{!isClientMode && !isWindowsMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
|
||||
@@ -123,5 +123,40 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
windows: {
|
||||
status: () => request("/api/windows/status"),
|
||||
profiles: {
|
||||
get: () => request("/api/windows/profiles"),
|
||||
save: (profiles) =>
|
||||
request("/api/windows/profiles", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profiles }),
|
||||
}),
|
||||
scan: (profiles) =>
|
||||
request("/api/windows/profiles/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profiles }),
|
||||
}),
|
||||
apply: () =>
|
||||
request("/api/windows/profiles/apply", {
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
targets: {
|
||||
get: () => request("/api/windows/targets"),
|
||||
save: (targets) =>
|
||||
request("/api/windows/targets", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ targets }),
|
||||
}),
|
||||
},
|
||||
service: (service, action) =>
|
||||
request("/api/windows/service", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ service, action }),
|
||||
}),
|
||||
logs: () => request("/api/windows/logs"),
|
||||
},
|
||||
|
||||
configValidate: () => request("/api/config/validate", { method: "POST" }),
|
||||
};
|
||||
|
||||
@@ -8,10 +8,18 @@ const NAV = [
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
const WINDOWS_NAV = [
|
||||
{ id: 'overview', label: 'Overview', ico: 'O' },
|
||||
{ id: 'logs', label: 'Logs', ico: 'L' },
|
||||
{ id: 'settings', label: 'Settings', ico: 'S' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
const items = mode === 'windows'
|
||||
? WINDOWS_NAV
|
||||
: mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
|
||||
@@ -26,17 +26,22 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
const isWindows = state?.mode === 'windows';
|
||||
const brand = isWindows ? 'VPN Proxy Windows' : isClient ? 'VPN Client' : 'VPN Gateway';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
{brand}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
<StatusBadge status={status} />
|
||||
{activeServer && (
|
||||
{isWindows && (
|
||||
<small className="muted">App profiles and ProxiFyre routing</small>
|
||||
)}
|
||||
{!isWindows && activeServer && (
|
||||
<div className="status-text">
|
||||
<strong>
|
||||
{flagFor(activeServer)} {activeServer.tag}
|
||||
@@ -47,29 +52,31 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!activeServer && (
|
||||
{!isWindows && !activeServer && (
|
||||
<small className="muted">Сервер не выбран</small>
|
||||
)}
|
||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
||||
{!isWindows && traffic && <span className="badge neutral">{traffic}</span>}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!isClient && dirty && (
|
||||
{!isClient && !isWindows && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{!isClient && state?.previousTag && (
|
||||
{!isClient && !isWindows && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
{!isWindows && (
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
261
src/web/components/WindowsOverviewPage.jsx
Normal file
261
src/web/components/WindowsOverviewPage.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function targetLabel(target) {
|
||||
if (!target) return 'No proxy target';
|
||||
return `${target.name} - ${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function routeTitle(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
const singbox = helper?.result?.singbox || helper?.singbox;
|
||||
if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box';
|
||||
if (proxifyre === 'Running') return 'Apps are routed through an existing proxy';
|
||||
return 'App routing is stopped';
|
||||
}
|
||||
|
||||
function routeState(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
if (proxifyre === 'Running') return 'running';
|
||||
if (helper?.success === false) return 'error';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
function emptyProfile() {
|
||||
return {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: 'New profile',
|
||||
enabled: true,
|
||||
proxyTargetId: 'local-singbox',
|
||||
protocols: ['TCP', 'UDP'],
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ProfileList({ profiles, selectedId, onSelect }) {
|
||||
return (
|
||||
<div className="win-profile-list">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
|
||||
onClick={() => onSelect(profile.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
|
||||
<span>
|
||||
<strong>{profile.name}</strong>
|
||||
<small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
|
||||
</span>
|
||||
<em>{profile.resolvedCount ?? profile.items.length}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDetails({ profile, targets, onChange }) {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const [newType, setNewType] = useState('process');
|
||||
if (!profile) {
|
||||
return <div className="win-profile-empty">Select or add a profile.</div>;
|
||||
}
|
||||
function patch(patchValue) {
|
||||
onChange({ ...profile, ...patchValue });
|
||||
}
|
||||
function addItem() {
|
||||
const value = newItem.trim();
|
||||
if (!value) return;
|
||||
patch({
|
||||
items: [
|
||||
...profile.items,
|
||||
{ type: newType, value, recursive: newType === 'folder' },
|
||||
],
|
||||
});
|
||||
setNewItem('');
|
||||
}
|
||||
return (
|
||||
<div className="win-detail">
|
||||
<label className="checkbox win-enabled">
|
||||
<input
|
||||
checked={profile.enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
Enabled profile
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={profile.name}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Proxy target</span>
|
||||
<select
|
||||
className="select"
|
||||
value={profile.proxyTargetId}
|
||||
onChange={(event) => patch({ proxyTargetId: event.target.value })}
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<option key={target.id} value={target.id}>{targetLabel(target)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="win-add-item">
|
||||
<select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
|
||||
<option value="process">Process</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="exe">EXE file</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
value={newItem}
|
||||
placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
|
||||
onChange={(event) => setNewItem(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && addItem()}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={addItem} type="button">Add</button>
|
||||
</div>
|
||||
<div className="win-items">
|
||||
{profile.items.map((item, index) => (
|
||||
<div key={`${item.type}-${item.value}-${index}`} className="win-item">
|
||||
<span>{item.value}</span>
|
||||
<small>{item.type}</small>
|
||||
<button
|
||||
className="btn btn-link sm"
|
||||
onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsOverviewPage({ pushToast }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const data = await api.windows.status();
|
||||
setStatus(data);
|
||||
const nextProfiles = data.profiles || [];
|
||||
setProfiles(nextProfiles);
|
||||
setTargets(data.targets || []);
|
||||
setSelectedId((current) => current || nextProfiles[0]?.id || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message }));
|
||||
const timer = setInterval(() => load().catch(() => {}), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const selected = useMemo(
|
||||
() => profiles.find((profile) => profile.id === selectedId) || null,
|
||||
[profiles, selectedId],
|
||||
);
|
||||
|
||||
function replaceProfile(nextProfile) {
|
||||
setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile));
|
||||
}
|
||||
|
||||
async function saveProfiles(nextProfiles = profiles) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.windows.profiles.save(nextProfiles);
|
||||
setProfiles(data.summaries || data.profiles || []);
|
||||
pushToast?.({ kind: 'success', title: 'Profiles saved' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Save failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProfiles() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.windows.profiles.save(profiles);
|
||||
await api.windows.profiles.apply();
|
||||
await load();
|
||||
pushToast?.({ kind: 'success', title: 'ProxiFyre updated' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Apply failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addProfile() {
|
||||
const profile = emptyProfile();
|
||||
setProfiles((prev) => [...prev, profile]);
|
||||
setSelectedId(profile.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="windows-page">
|
||||
<section className="windows-status-panel">
|
||||
<div className="windows-status-main">
|
||||
<span className={`windows-status-dot ${routeState(status)}`} />
|
||||
<div>
|
||||
<h1>{routeTitle(status)}</h1>
|
||||
<p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="windows-route-line">
|
||||
<span>Selected apps</span><b>-></b><span>ProxiFyre</span><b>-></b><span>Proxy target</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="windows-workspace">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>Profiles</h2>
|
||||
<small>{profiles.filter((profile) => profile.enabled).length} enabled</small>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={addProfile} type="button">Add profile</button>
|
||||
</div>
|
||||
<ProfileList profiles={profiles} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>{selected?.name || 'Profile'}</h2>
|
||||
<small>{selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'}</small>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={applyProfiles} type="button">Apply changes</button>
|
||||
</div>
|
||||
<ProfileDetails profile={selected} targets={targets} onChange={replaceProfile} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel windows-activity">
|
||||
<div className="panel-head">
|
||||
<h2>Recent activity</h2>
|
||||
<button className="btn btn-secondary" disabled={busy} onClick={() => saveProfiles()} type="button">Save only</button>
|
||||
</div>
|
||||
{(status?.activity || []).slice(0, 5).map((entry) => (
|
||||
<div key={entry.id} className="windows-activity-row">
|
||||
<strong>{entry.type}</strong>
|
||||
<span>{entry.message}</span>
|
||||
<small>{entry.ts}</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1096,6 +1096,294 @@ code, .mono {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ Windows overview ============ */
|
||||
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.windows-mode .app-main {
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.windows-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.windows-page .panel {
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.windows-page .panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.windows-page .panel-head h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.windows-status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.windows-status-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.windows-status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--subtle);
|
||||
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
.windows-status-dot.running {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-dot.stopped {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-dot.error {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 6px rgba(255, 92, 92, 0.12);
|
||||
}
|
||||
|
||||
.windows-status-panel h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.windows-status-panel p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.windows-route-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.windows-route-line span {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.windows-route-line b {
|
||||
color: var(--subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.windows-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(420px, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.win-profile-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-profile-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.win-profile-row:hover {
|
||||
border-color: #4c6d88;
|
||||
}
|
||||
|
||||
.win-profile-row.active {
|
||||
border-color: var(--info);
|
||||
background: rgba(142, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.win-profile-row strong,
|
||||
.win-profile-row small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.win-profile-row small,
|
||||
.win-profile-row em {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.win-profile-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #172536;
|
||||
color: var(--subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.win-profile-check.on {
|
||||
background: rgba(109, 255, 157, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.win-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.win-detail label:not(.checkbox) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.win-enabled {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.win-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.win-item span {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.win-item small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.win-profile-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.windows-activity {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.windows-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #253341;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.windows-activity-row strong {
|
||||
color: var(--info);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.windows-activity-row span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.windows-status-panel,
|
||||
.windows-workspace,
|
||||
.windows-activity-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* For drawer rule editor */
|
||||
.field-row {
|
||||
display: grid;
|
||||
|
||||
61
test/server/singbox-windows-mode.test.js
Normal file
61
test/server/singbox-windows-mode.test.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
process.env.APP_MODE = "windows";
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-windows-test-"));
|
||||
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
|
||||
process.env.PROXY_PORT = "1080";
|
||||
process.env.PROXY_BIND_IP = "127.0.0.1";
|
||||
|
||||
const { settings } = await import(
|
||||
`../../src/server/config.js?windows-mode=${Date.now()}`
|
||||
);
|
||||
const { buildGatewayConfig } = await import(
|
||||
`../../src/server/singbox.js?windows-mode=${Date.now()}`
|
||||
);
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [
|
||||
{
|
||||
type: "vless",
|
||||
tag: "win-vpn",
|
||||
server: "vpn.example.test",
|
||||
server_port: 443,
|
||||
uuid: "00000000-0000-4000-8000-000000000000",
|
||||
tls: { enabled: true },
|
||||
},
|
||||
],
|
||||
customRules: [],
|
||||
};
|
||||
|
||||
test("settings accepts APP_MODE=windows", () => {
|
||||
assert.equal(settings.appMode, "windows");
|
||||
assert.equal(settings.proxyPort, 1080);
|
||||
assert.equal(settings.bindIp, "127.0.0.1");
|
||||
});
|
||||
|
||||
test("windows mode exposes only local mixed proxy inbound", () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, "win-vpn");
|
||||
|
||||
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
|
||||
assert.equal(config.inbounds[0].type, "mixed");
|
||||
assert.equal(config.inbounds[0].listen, "127.0.0.1");
|
||||
assert.equal(config.inbounds[0].listen_port, 1080);
|
||||
});
|
||||
|
||||
test("windows mode routes mixed proxy to selected VPN outbound", () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, "win-vpn");
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "win-vpn" },
|
||||
]);
|
||||
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), [
|
||||
"win-vpn",
|
||||
"direct",
|
||||
"block",
|
||||
]);
|
||||
});
|
||||
26
test/server/windows-api-model.test.js
Normal file
26
test/server/windows-api-model.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildProxiFyreConfig,
|
||||
normalizeProxyTargets,
|
||||
normalizeWindowsProfiles,
|
||||
summarizeProfiles,
|
||||
} from "../../src/server/windowsProfiles.js";
|
||||
|
||||
test("windows API model returns summaries and generated config", () => {
|
||||
const profiles = normalizeWindowsProfiles([
|
||||
{
|
||||
name: "Discord",
|
||||
proxyTargetId: "local-singbox",
|
||||
items: [{ type: "process", value: "Discord" }],
|
||||
},
|
||||
]);
|
||||
const targets = normalizeProxyTargets([]);
|
||||
|
||||
const summaries = summarizeProfiles(profiles, targets);
|
||||
const config = buildProxiFyreConfig(profiles, targets);
|
||||
|
||||
assert.equal(summaries[0].resolvedCount, 1);
|
||||
assert.equal(summaries[0].target.id, "local-singbox");
|
||||
assert.deepEqual(config.proxies[0].appNames, ["Discord"]);
|
||||
});
|
||||
74
test/server/windows-helper.test.js
Normal file
74
test/server/windows-helper.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createWindowsHelper } from "../../src/server/windowsHelper.js";
|
||||
|
||||
test("windows helper sends action and payload as JSON", async () => {
|
||||
const calls = [];
|
||||
const helper = createWindowsHelper({
|
||||
helperPath: "scripts/windows/helper.ps1",
|
||||
runner: async (command, args, options) => {
|
||||
calls.push({ command, args, input: options.input });
|
||||
return {
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
success: true,
|
||||
action: "status.get",
|
||||
result: { proxifyre: "Running" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await helper.run("status.get", { service: "ProxiFyre" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
success: true,
|
||||
action: "status.get",
|
||||
result: { proxifyre: "Running" },
|
||||
});
|
||||
assert.equal(calls[0].command, "pwsh");
|
||||
assert.deepEqual(calls[0].args, [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"scripts/windows/helper.ps1",
|
||||
]);
|
||||
assert.deepEqual(JSON.parse(calls[0].input), {
|
||||
action: "status.get",
|
||||
payload: { service: "ProxiFyre" },
|
||||
});
|
||||
});
|
||||
|
||||
test("windows helper normalizes non-zero exit into structured error", async () => {
|
||||
const helper = createWindowsHelper({
|
||||
helperPath: "scripts/windows/helper.ps1",
|
||||
runner: async () => ({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
stderr: "service failed",
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => helper.run("service.restart", { name: "proxifyre" }),
|
||||
/Windows helper failed: service failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("windows helper rejects invalid JSON stdout", async () => {
|
||||
const helper = createWindowsHelper({
|
||||
helperPath: "scripts/windows/helper.ps1",
|
||||
runner: async () => ({
|
||||
status: 0,
|
||||
stdout: "not-json",
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => helper.run("status.get", {}),
|
||||
/Windows helper returned invalid JSON/,
|
||||
);
|
||||
});
|
||||
157
test/server/windows-profiles.test.js
Normal file
157
test/server/windows-profiles.test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildProxiFyreConfig,
|
||||
normalizeProxyTargets,
|
||||
normalizeWindowsProfiles,
|
||||
resolveProfileItems,
|
||||
} from "../../src/server/windowsProfiles.js";
|
||||
|
||||
test("normalizeWindowsProfiles keeps process folder and exe source items", () => {
|
||||
const profiles = normalizeWindowsProfiles([
|
||||
{
|
||||
id: "Discord + Vesktop",
|
||||
name: "Discord + Vesktop",
|
||||
enabled: true,
|
||||
proxyTargetId: "local-singbox",
|
||||
protocols: ["TCP", "UDP", "bad"],
|
||||
items: [
|
||||
{ type: "process", value: "Discord.exe" },
|
||||
{ type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true },
|
||||
{ type: "exe", value: "C:\\Games\\SomeGame\\game.exe" },
|
||||
{ type: "bad", value: "ignored" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(profiles, [
|
||||
{
|
||||
id: "discord-vesktop",
|
||||
name: "Discord + Vesktop",
|
||||
enabled: true,
|
||||
proxyTargetId: "local-singbox",
|
||||
protocols: ["TCP", "UDP"],
|
||||
items: [
|
||||
{ type: "process", value: "Discord", recursive: false },
|
||||
{ type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true },
|
||||
{ type: "exe", value: "C:\\Games\\SomeGame\\game.exe", recursive: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeProxyTargets always includes local-singbox", () => {
|
||||
const targets = normalizeProxyTargets([
|
||||
{ id: "gateway", name: "Home gateway", host: "192.168.50.111", port: 8080 },
|
||||
]);
|
||||
|
||||
assert.deepEqual(targets, [
|
||||
{
|
||||
id: "local-singbox",
|
||||
name: "Local sing-box",
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
managed: true,
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
name: "Home gateway",
|
||||
protocol: "socks5",
|
||||
host: "192.168.50.111",
|
||||
port: 8080,
|
||||
managed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveProfileItems expands folders and exe paths into process names", () => {
|
||||
const files = new Map([
|
||||
["C:\\Users\\me\\App\\a.exe", true],
|
||||
["C:\\Users\\me\\App\\nested\\b.exe", true],
|
||||
["C:\\Games\\Game\\game.exe", true],
|
||||
]);
|
||||
const dirs = new Map([
|
||||
["C:\\Users\\me\\App", ["a.exe", "nested", "note.txt"]],
|
||||
["C:\\Users\\me\\App\\nested", ["b.exe"]],
|
||||
]);
|
||||
const fsAdapter = {
|
||||
existsSync: (value) => files.has(value) || dirs.has(value),
|
||||
statSync: (value) => ({
|
||||
isDirectory: () => dirs.has(value),
|
||||
isFile: () => files.has(value),
|
||||
}),
|
||||
readdirSync: (value, options) =>
|
||||
dirs.get(value).map((name) => ({
|
||||
name,
|
||||
isDirectory: () => dirs.has(`${value}\\${name}`),
|
||||
isFile: () => files.has(`${value}\\${name}`),
|
||||
})),
|
||||
};
|
||||
|
||||
const resolved = resolveProfileItems(
|
||||
[
|
||||
{ type: "process", value: "Discord", recursive: false },
|
||||
{ type: "folder", value: "C:\\Users\\me\\App", recursive: true },
|
||||
{ type: "exe", value: "C:\\Games\\Game\\game.exe", recursive: false },
|
||||
],
|
||||
{ fsAdapter, pathSep: "\\" },
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved.map((item) => item.appName), [
|
||||
"Discord",
|
||||
"a",
|
||||
"b",
|
||||
"game",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildProxiFyreConfig groups enabled profiles by target", () => {
|
||||
const profiles = normalizeWindowsProfiles([
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
enabled: true,
|
||||
proxyTargetId: "local-singbox",
|
||||
protocols: ["TCP", "UDP"],
|
||||
items: [{ type: "process", value: "Discord" }],
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
enabled: true,
|
||||
proxyTargetId: "gateway",
|
||||
protocols: ["TCP"],
|
||||
items: [{ type: "process", value: "Code" }],
|
||||
},
|
||||
{
|
||||
id: "off",
|
||||
name: "Disabled",
|
||||
enabled: false,
|
||||
proxyTargetId: "local-singbox",
|
||||
items: [{ type: "process", value: "Ignored" }],
|
||||
},
|
||||
]);
|
||||
const targets = normalizeProxyTargets([
|
||||
{ id: "gateway", name: "Gateway", host: "192.168.50.111", port: 8080 },
|
||||
]);
|
||||
|
||||
const config = buildProxiFyreConfig(profiles, targets);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
logLevel: "Info",
|
||||
proxies: [
|
||||
{
|
||||
appNames: ["Discord"],
|
||||
socks5ProxyEndpoint: "127.0.0.1:1080",
|
||||
supportedProtocols: ["TCP", "UDP"],
|
||||
},
|
||||
{
|
||||
appNames: ["Code"],
|
||||
socks5ProxyEndpoint: "192.168.50.111:8080",
|
||||
supportedProtocols: ["TCP"],
|
||||
},
|
||||
],
|
||||
excludes: [],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user