Compare commits
8 Commits
b5d4c61783
...
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/
|
data/
|
||||||
.vpn-proxy/
|
.vpn-proxy/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
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
|
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
|
# 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";
|
import path from "node:path";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
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 = {
|
export const settings = {
|
||||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
appMode,
|
||||||
port: Number(process.env.PORT || 3456),
|
port: Number(process.env.PORT || 3456),
|
||||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||||
@@ -22,10 +26,24 @@ export const settings = {
|
|||||||
devicesPath: path.join(dataDir, "devices.json"),
|
devicesPath: path.join(dataDir, "devices.json"),
|
||||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.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 || "",
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
hwidPath: path.join(dataDir, "hwid"),
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||||
logLevel: process.env.LOG_LEVEL || "info",
|
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";
|
} from "./sharedProxy.js";
|
||||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||||
import { tcpPing, resolveHost } from "./ping.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 APPLY_HISTORY_LIMIT = 10;
|
||||||
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
||||||
@@ -603,6 +611,8 @@ function publicState() {
|
|||||||
const customRules = readJson(settings.customRulesPath, []);
|
const customRules = readJson(settings.customRulesPath, []);
|
||||||
const deviceProfiles = readDeviceProfiles();
|
const deviceProfiles = readDeviceProfiles();
|
||||||
const clientSettings = readClientSettings();
|
const clientSettings = readClientSettings();
|
||||||
|
const windowsTargets =
|
||||||
|
settings.appMode === "windows" ? readProxyTargets() : [];
|
||||||
const { subscriptionUrl, ...rest } = state;
|
const { subscriptionUrl, ...rest } = state;
|
||||||
return {
|
return {
|
||||||
mode: settings.appMode,
|
mode: settings.appMode,
|
||||||
@@ -634,6 +644,17 @@ function publicState() {
|
|||||||
directBypassCount,
|
directBypassCount,
|
||||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||||
directBypassAvailable: IPSET_AVAILABLE,
|
directBypassAvailable: IPSET_AVAILABLE,
|
||||||
|
windows:
|
||||||
|
settings.appMode === "windows"
|
||||||
|
? {
|
||||||
|
profiles: summarizeProfiles(
|
||||||
|
readWindowsProfiles(),
|
||||||
|
windowsTargets,
|
||||||
|
),
|
||||||
|
targets: windowsTargets,
|
||||||
|
activity: readWindowsActivity().slice(-20).reverse(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
...rest,
|
...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) {
|
async function applySelectedServer(selectedTag) {
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
if (!cached?.config) {
|
if (!cached?.config) {
|
||||||
@@ -809,6 +886,99 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, config });
|
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") {
|
if (req.method === "GET" && req.url === "/api/logs") {
|
||||||
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
|
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ export function buildGatewayConfig(
|
|||||||
{ bypassAll = false } = {},
|
{ bypassAll = false } = {},
|
||||||
) {
|
) {
|
||||||
const customRuleSets = readCustomRuleSets();
|
const customRuleSets = readCustomRuleSets();
|
||||||
|
const proxyOnlyMode =
|
||||||
|
settings.appMode === "client" || settings.appMode === "windows";
|
||||||
const clientMode = settings.appMode === "client";
|
const clientMode = settings.appMode === "client";
|
||||||
const clientSettings = clientMode ? readClientSettings() : null;
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
const sharedOutbound =
|
const sharedOutbound =
|
||||||
@@ -295,7 +297,7 @@ export function buildGatewayConfig(
|
|||||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||||
const inbounds = [
|
const inbounds = [
|
||||||
...(clientMode
|
...(proxyOnlyMode
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -338,13 +340,16 @@ export function buildGatewayConfig(
|
|||||||
{ type: "block", tag: "block" },
|
{ type: "block", tag: "block" },
|
||||||
],
|
],
|
||||||
route: {
|
route: {
|
||||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
rule_set:
|
||||||
|
bypassAll || proxyOnlyMode
|
||||||
|
? []
|
||||||
|
: ruleSets(customRuleSets, vpnOutbound.tag),
|
||||||
rules: bypassAll
|
rules: bypassAll
|
||||||
? [{ ip_is_private: true, outbound: "direct" }]
|
? [{ ip_is_private: true, outbound: "direct" }]
|
||||||
: clientMode
|
: proxyOnlyMode
|
||||||
? proxyOnlyRules
|
? proxyOnlyRules
|
||||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||||
includeTransparent: !clientMode,
|
includeTransparent: !proxyOnlyMode,
|
||||||
}),
|
}),
|
||||||
final: "direct",
|
final: "direct",
|
||||||
auto_detect_interface: true,
|
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 { StatusPane } from './components/StatusPane.jsx';
|
||||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||||
|
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
|
||||||
import { ServersPage } from './components/ServersPage.jsx';
|
import { ServersPage } from './components/ServersPage.jsx';
|
||||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||||
import { LogsPage } from './components/LogsPage.jsx';
|
import { LogsPage } from './components/LogsPage.jsx';
|
||||||
@@ -97,6 +98,9 @@ function App() {
|
|||||||
if (state?.mode === 'client' && page !== 'overview') {
|
if (state?.mode === 'client' && page !== 'overview') {
|
||||||
navigate('overview');
|
navigate('overview');
|
||||||
}
|
}
|
||||||
|
if (state?.mode === 'windows' && (page === 'servers' || page === 'routing')) {
|
||||||
|
navigate('overview');
|
||||||
|
}
|
||||||
}, [state?.mode, page]);
|
}, [state?.mode, page]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
@@ -381,6 +385,7 @@ function App() {
|
|||||||
[servers, state?.selectedTag],
|
[servers, state?.selectedTag],
|
||||||
);
|
);
|
||||||
const isClientMode = state?.mode === 'client';
|
const isClientMode = state?.mode === 'client';
|
||||||
|
const isWindowsMode = state?.mode === 'windows';
|
||||||
|
|
||||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||||
const dirtyDevices = Boolean(
|
const dirtyDevices = Boolean(
|
||||||
@@ -409,11 +414,14 @@ function App() {
|
|||||||
onTryApply={rollback}
|
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} />}
|
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
{(page === 'overview' || isClientMode) && (
|
{page === 'overview' && isWindowsMode && (
|
||||||
|
<WindowsOverviewPage pushToast={pushToast} />
|
||||||
|
)}
|
||||||
|
{(page === 'overview' || isClientMode) && !isWindowsMode && (
|
||||||
isClientMode ? (
|
isClientMode ? (
|
||||||
<ClientOverviewPage
|
<ClientOverviewPage
|
||||||
state={state}
|
state={state}
|
||||||
@@ -447,7 +455,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{page === 'servers' && !isClientMode && (
|
{page === 'servers' && !isClientMode && !isWindowsMode && (
|
||||||
<ServersPage
|
<ServersPage
|
||||||
state={state}
|
state={state}
|
||||||
servers={servers}
|
servers={servers}
|
||||||
@@ -463,7 +471,7 @@ function App() {
|
|||||||
pushToast={pushToast}
|
pushToast={pushToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === 'routing' && !isClientMode && (
|
{page === 'routing' && !isClientMode && !isWindowsMode && (
|
||||||
<RoutingPage
|
<RoutingPage
|
||||||
rules={customRules}
|
rules={customRules}
|
||||||
saveStatus={rulesSaveStatus}
|
saveStatus={rulesSaveStatus}
|
||||||
@@ -497,7 +505,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sticky bar — для routing/servers */}
|
{/* Sticky bar — для routing/servers */}
|
||||||
{(page === 'routing' && dirtyRouting) && (
|
{(page === 'routing' && dirtyRouting && !isWindowsMode) && (
|
||||||
<div className="sticky-bar">
|
<div className="sticky-bar">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||||
@@ -522,7 +530,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(page === 'servers' && dirtyServer) && (
|
{(page === 'servers' && dirtyServer && !isWindowsMode) && (
|
||||||
<div className="sticky-bar">
|
<div className="sticky-bar">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="dot warning" />
|
<span className="dot warning" />
|
||||||
@@ -539,7 +547,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isClientMode && (
|
{!isClientMode && !isWindowsMode && (
|
||||||
<StatusPane
|
<StatusPane
|
||||||
state={state}
|
state={state}
|
||||||
busy={busy}
|
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" }),
|
configValidate: () => request("/api/config/validate", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ const NAV = [
|
|||||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
{ 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' }) {
|
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||||
const items = mode === 'client'
|
const items = mode === 'windows'
|
||||||
? NAV.filter((item) => item.id !== 'routing')
|
? WINDOWS_NAV
|
||||||
: NAV;
|
: mode === 'client'
|
||||||
|
? NAV.filter((item) => item.id !== 'routing')
|
||||||
|
: NAV;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sidebar">
|
<nav className="sidebar">
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isClient = state?.mode === 'client';
|
const isClient = state?.mode === 'client';
|
||||||
|
const isWindows = state?.mode === 'windows';
|
||||||
|
const brand = isWindows ? 'VPN Proxy Windows' : isClient ? 'VPN Client' : 'VPN Gateway';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-brand">
|
<div className="topbar-brand">
|
||||||
<span className="logo-dot" />
|
<span className="logo-dot" />
|
||||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
{brand}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-status">
|
<div className="topbar-status">
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
{activeServer && (
|
{isWindows && (
|
||||||
|
<small className="muted">App profiles and ProxiFyre routing</small>
|
||||||
|
)}
|
||||||
|
{!isWindows && activeServer && (
|
||||||
<div className="status-text">
|
<div className="status-text">
|
||||||
<strong>
|
<strong>
|
||||||
{flagFor(activeServer)} {activeServer.tag}
|
{flagFor(activeServer)} {activeServer.tag}
|
||||||
@@ -47,29 +52,31 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!activeServer && (
|
{!isWindows && !activeServer && (
|
||||||
<small className="muted">Сервер не выбран</small>
|
<small className="muted">Сервер не выбран</small>
|
||||||
)}
|
)}
|
||||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
{!isWindows && traffic && <span className="badge neutral">{traffic}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
{!isClient && dirty && (
|
{!isClient && !isWindows && dirty && (
|
||||||
<span className="badge warning">● Несохранённые изменения</span>
|
<span className="badge warning">● Несохранённые изменения</span>
|
||||||
)}
|
)}
|
||||||
{!isClient && state?.previousTag && (
|
{!isClient && !isWindows && state?.previousTag && (
|
||||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||||
↶ Откат
|
↶ Откат
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!isWindows && (
|
||||||
className="btn btn-secondary sm"
|
<button
|
||||||
onClick={onRestart}
|
className="btn btn-secondary sm"
|
||||||
disabled={!state?.configExists}
|
onClick={onRestart}
|
||||||
title="Перезапустить sing-box"
|
disabled={!state?.configExists}
|
||||||
>
|
title="Перезапустить sing-box"
|
||||||
↻ Перезапуск
|
>
|
||||||
</button>
|
↻ Перезапуск
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 */
|
/* For drawer rule editor */
|
||||||
.field-row {
|
.field-row {
|
||||||
display: grid;
|
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