7 Commits

90 changed files with 2034 additions and 24878 deletions

View File

@@ -13,7 +13,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst
После запуска по умолчанию: После запуска по умолчанию:
- UI: `http://127.0.0.1:3456` - UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его - HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `80808090`
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся: Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
@@ -53,59 +53,38 @@ 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: standalone desktop client direction ## Windows: app proxy client
Windows app routing lives in a separate Tauri 2 desktop utility, not as Windows mode restores the native workflow for Discord, Vesktop, games, and other apps that do not expose proxy settings.
`APP_MODE=windows` inside the current Node gateway/client server. The active
workspace slice is `apps/windows-client`.
Active design documents: Run PowerShell 7 as Administrator. While this branch is being tested, install from `codex-windows-client`:
- Product/tech brief: `docs/windows-client-product-tech-brief.md`
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`
- Windows client README: `apps/windows-client/README.md`
Target shape:
- Control App: compact Windows UI for status, profiles, targets, components,
logs, and diagnostics.
- Proxyfier Layer: adapter boundary with ProxiFyre as the first engine for
per-app TCP/UDP routing.
- Local sing-box: optional local runtime; external SOCKS5/HTTP targets must
work without it.
Development checks:
```powershell ```powershell
cd apps/windows-client irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/codex-windows-client/scripts/install-windows-client.ps1 | iex
npm install
npm run build
npm run tauri -- info
cd src-tauri
cargo test
``` ```
Native Tauri build requires WebView2, Rust/rustup, and Visual Studio Build Installer modes:
Tools with MSVC and Windows SDK components. In the current checkpoint, frontend
builds pass, while native Rust/Tauri tests require that Windows toolchain.
The three Windows pieces are installed and operated separately: - `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 ```powershell
cd apps/windows-client & "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -OpenUi
& .\scripts\install-control-app.ps1 -PlanOnly & "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -Status
& .\scripts\install-proxyfier.ps1 -PlanOnly & "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -RestartServices
& .\scripts\install-singbox.ps1 -PlanOnly
``` ```
`-PlanOnly` returns structured JSON without install side effects. Real install 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.
or service operations must be explicit; profile apply must not silently install
Proxyfier or Local sing-box.
Windows source configuration is owned by JSON under
`C:\ProgramData\VpnProxy\config`. Generated ProxiFyre and sing-box files under
`C:\ProgramData\VpnProxy\generated` are derived artifacts.
--- ---
@@ -129,8 +108,6 @@ Windows source configuration is owned by JSON under
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
│ └─ FORWARD + MASQUERADE → обычный internet path
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш ├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
├─ приватные CIDR (RFC1918, ...) → RETURN ├─ приватные CIDR (RFC1918, ...) → RETURN
└─ TCP/UDP → TPROXY :7895 └─ TCP/UDP → TPROXY :7895
@@ -182,12 +159,8 @@ ip route replace local 0.0.0.0/0 dev lo table 100
# Цепочка iptables (порядок правил — критичен) # Цепочка iptables (порядок правил — критичен)
iptables -t mangle -N VPN_PROXY_TPROXY iptables -t mangle -N VPN_PROXY_TPROXY
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
iptables -N VPN_PROXY_FWD_BYPASS
iptables -t nat -N VPN_PROXY_NAT_BYPASS
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box -m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты -m mark --mark 1 → RETURN # уже помеченные пакеты
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true -m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса -d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1 -p tcp → TPROXY :7895 mark 1
@@ -198,10 +171,6 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно. При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
ipset-кэш намеренно **не** очищается — записи истекают по TTL. ipset-кэш намеренно **не** очищается — записи истекают по TTL.
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
### 2. Маршрутизация внутри sing-box ### 2. Маршрутизация внутри sing-box
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**: Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
@@ -430,10 +399,9 @@ UI доступен на `http://<gateway-ip>:3456`.
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически | | `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` | | `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` | | `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` | | `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` | | `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом | | `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса | | `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
| `PORT` | `3456` | Порт веб-интерфейса | | `PORT` | `3456` | Порт веб-интерфейса |
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror | | `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
@@ -442,10 +410,6 @@ UI доступен на `http://<gateway-ip>:3456`.
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base | | `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound | | `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box | | `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) | | `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct | | `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
| `LOG_LEVEL` | `info` | Уровень логов sing-box | | `LOG_LEVEL` | `info` | Уровень логов sing-box |

View File

@@ -1,4 +0,0 @@
node_modules/
dist/
src-tauri/target/

View File

@@ -1,116 +0,0 @@
# VPN Proxy Windows Client
Standalone Windows desktop utility for app-level proxy routing. This app is
separate from the current Docker gateway/client runtime and must not be wired
through `APP_MODE=windows`.
## Components
- Control App: Tauri 2 + React/TypeScript UI and Rust command layer.
- Proxyfier Layer: ProxiFyre adapter for per-application routing.
- Local sing-box: optional local runtime, used only by targets that explicitly
require `singbox`.
External SOCKS5 targets are the MVP path and do not require Local sing-box.
## Source And Generated Files
Source configuration is owned by Rust domain models and JSON files under:
```text
C:\ProgramData\VpnProxy\config\profiles.json
C:\ProgramData\VpnProxy\config\targets.json
C:\ProgramData\VpnProxy\config\components.json
C:\ProgramData\VpnProxy\state\activity.json
```
Generated artifacts are derived and can be recreated:
```text
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
C:\ProgramData\VpnProxy\generated\sing-box-config.json
```
## Development
```powershell
cd apps/windows-client
npm install
npm run build
```
Run the browser preview shell:
```powershell
npm run dev -- --host 127.0.0.1
```
Run Tauri checks when the native Windows toolchain is installed:
```powershell
npm run tauri -- info
npm run tauri -- dev
npm run tauri -- build
```
Run Rust tests when Rust/Cargo are installed:
```powershell
cd apps/windows-client/src-tauri
cargo test
```
Native Tauri build requires WebView2, Rust via rustup, and Visual Studio Build
Tools with MSVC and Windows SDK components.
## Explicit Installer Boundaries
Installer scripts are explicit per component and return structured JSON in
`-PlanOnly` mode:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
Installers must be launched intentionally by the user or by a future narrow
helper permission. Profile apply must not silently install Control App,
Proxyfier, or Local sing-box.
## Existing Proxyfier Detection
The app detects an already installed Proxyfier layer before showing component
status or applying profiles. Detection checks:
- uninstall registry entries for `ProxiFyre` and `Proxifier`;
- common install folders such as `C:\Tools\ProxiFyre`,
`%ProgramFiles%\ProxiFyre`, and `%ProgramFiles%\Proxifier`;
- running `ProxiFyre` / `Proxifier` processes and the `ProxiFyreService`
service.
For portable installs, set an override before launching the app:
```powershell
$env:VPN_PROXY_PROXIFYRE_ROOT = 'D:\Tools\ProxiFyre'
npm run tauri -- dev
```
`ProxiFyre` installs are compatible with the current generated
`app-config.json` apply path. Plain `Proxifier` installs are detected and shown,
but automatic profile apply is not enabled for them yet because they use a
different profile format.
## MVP Verification Flow
1. Start the Control App or browser preview.
2. Confirm Components shows Control App, Proxyfier Layer, and optional Local
sing-box separately.
3. Add or keep an external SOCKS5 target.
4. Add a process/folder/exe profile such as Discord.
5. Apply profiles and verify generated ProxiFyre config plus activity entry.
6. Install Proxyfier separately before applying to a real service.
7. Install and start Local sing-box only when using a local target.
Task evidence is recorded in
`docs/goals/windows-modular-client/EVIDENCE.md`.

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPN Proxy для Windows</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "vpn-proxy-windows-client",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Standalone Windows desktop proxy management app for VPN Proxy.",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
}
}

View File

@@ -1,79 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\VpnProxy\ControlApp",
[string]$DataRoot = "C:\ProgramData\VpnProxy",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
return $true
}
return $false
}
try {
$details = @{
installRoot = $InstallRoot
dataRoot = $DataRoot
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
$changed = $false
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
} catch {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
exit 1
}

View File

@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Tools\ProxiFyre",
[string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
packagePath = $PackagePath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "app-config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "proxyfier"
packagePath = $PackagePath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
exit 1
}

View File

@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
[string]$BinaryPath = "",
[string]$ServiceName = "VpnProxySingBox",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
binaryPath = $BinaryPath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-singbox.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "singbox"
binaryPath = $BinaryPath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
exit 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
[package]
name = "vpn-proxy-windows-client"
version = "0.1.0"
description = "Standalone Windows desktop proxy management app for VPN Proxy."
authors = ["VPN Proxy"]
edition = "2021"
[lib]
name = "vpn_proxy_windows_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.7.1"

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build();
}

View File

@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,23 +0,0 @@
use crate::models::ActivityEntry;
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
entries
}
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
let mut entries = sort_activity_desc(entries);
entries.truncate(limit);
entries
}
pub fn append_activity(
mut entries: Vec<ActivityEntry>,
entry: ActivityEntry,
limit: usize,
) -> Vec<ActivityEntry> {
entries.push(entry);
cap_activity(entries, limit)
}

View File

@@ -1,245 +0,0 @@
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use crate::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
ProxyProtocol, Target,
};
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use serde::{Deserialize, Serialize};
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxiFyreAdapter {
log_level: String,
bypass_lan: bool,
}
impl ProxiFyreAdapter {
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
Self {
log_level: log_level.into(),
bypass_lan,
}
}
pub fn generate_proxifyre_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxiFyreConfig, ProxyRouterError> {
let mut proxies = Vec::new();
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
let target = find_target(profile, request.targets)?;
ensure_target_supported(profile, target, request.components)?;
let app_names = app_names_for_profile(profile);
if app_names.is_empty() {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::EmptyProfileItems,
format!(
"В профиле '{}' нет приложений для маршрутизации",
profile.id
),
));
}
proxies.push(ProxiFyreProxy {
app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
supported_protocols: protocols_for_profile(profile),
});
}
Ok(ProxiFyreConfig {
log_level: self.log_level.clone(),
bypass_lan: self.bypass_lan,
proxies,
})
}
}
impl Default for ProxiFyreAdapter {
fn default() -> Self {
Self::new("Info", true)
}
}
impl ProxyRouterAdapter for ProxiFyreAdapter {
fn id(&self) -> &'static str {
PROXIFYRE_ADAPTER_ID
}
fn output_file_name(&self) -> &'static str {
PROXIFYRE_OUTPUT_FILE
}
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
let config = self.generate_proxifyre_config(request)?;
let enabled_profiles = config.proxies.len();
let routed_apps = config
.proxies
.iter()
.map(|proxy| proxy.app_names.len())
.sum();
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
ProxyRouterError::new(
ProxyRouterErrorKind::Serialization,
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
)
})?;
Ok(ProxyRouterGeneratedConfig {
adapter_id: self.id().to_string(),
output_file_name: self.output_file_name().to_string(),
contents,
enabled_profiles,
routed_apps,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreConfig {
#[serde(rename = "logLevel")]
pub log_level: String,
#[serde(rename = "bypassLan")]
pub bypass_lan: bool,
pub proxies: Vec<ProxiFyreProxy>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreProxy {
#[serde(rename = "appNames")]
pub app_names: Vec<String>,
#[serde(rename = "socks5ProxyEndpoint")]
pub socks5_proxy_endpoint: String,
#[serde(rename = "supportedProtocols")]
pub supported_protocols: Vec<String>,
}
fn find_target<'a>(
profile: &Profile,
targets: &'a [Target],
) -> Result<&'a Target, ProxyRouterError> {
targets
.iter()
.find(|target| target.id == profile.target_id)
.ok_or_else(|| {
ProxyRouterError::new(
ProxyRouterErrorKind::MissingTarget,
format!(
"Профиль '{}' ссылается на отсутствующую цель '{}'",
profile.id, profile.target_id
),
)
})
}
fn ensure_target_supported(
profile: &Profile,
target: &Target,
components: &[ComponentStatus],
) -> Result<(), ProxyRouterError> {
if target.protocol != ProxyProtocol::Socks5 {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::UnsupportedTargetProtocol,
format!(
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
target.id
),
));
}
if let Some(required_component) = &target.requires_component {
let Some(status) = components
.iter()
.find(|component| &component.id == required_component)
else {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::MissingRequiredComponent,
format!(
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
};
if !component_is_running(status) {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::RequiredComponentNotRunning,
format!(
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
}
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
let mut names = Vec::new();
for item in &profile.items {
let value = item.value.trim();
if value.is_empty() {
continue;
}
let app_name = match item.item_type {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
};
if !names.iter().any(|existing| existing == app_name) {
names.push(app_name.to_string());
}
}
names
}
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
let mut protocols = Vec::new();
for protocol in &profile.protocols {
let value = match protocol {
Protocol::Tcp => "TCP",
Protocol::Udp => "UDP",
};
if !protocols.iter().any(|existing| existing == value) {
protocols.push(value.to_string());
}
}
protocols
}
fn component_id_label(component_id: &ComponentId) -> &'static str {
match component_id {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxyfier",
ComponentId::Singbox => "singbox",
}
}

View File

@@ -1,67 +0,0 @@
use crate::models::{ComponentStatus, Profile, Target};
#[derive(Debug, Clone, Copy)]
pub struct ProxyRouterRequest<'a> {
pub profiles: &'a [Profile],
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
}
impl<'a> ProxyRouterRequest<'a> {
pub fn new(
profiles: &'a [Profile],
targets: &'a [Target],
components: &'a [ComponentStatus],
) -> Self {
Self {
profiles,
targets,
components,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterError {
pub kind: ProxyRouterErrorKind,
pub message: String,
}
impl ProxyRouterError {
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyRouterErrorKind {
EmptyProfileItems,
MissingTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTargetProtocol,
Serialization,
}
pub trait ProxyRouterAdapter {
fn id(&self) -> &'static str;
fn output_file_name(&self) -> &'static str;
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
}

View File

@@ -1,367 +0,0 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
};
use serde::{Deserialize, Serialize};
use std::{
env, fs,
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxAdapter {
log_level: String,
inbound_tag: String,
outbound_tag: String,
}
impl SingBoxAdapter {
pub fn new(
log_level: impl Into<String>,
inbound_tag: impl Into<String>,
outbound_tag: impl Into<String>,
) -> Self {
Self {
log_level: log_level.into(),
inbound_tag: inbound_tag.into(),
outbound_tag: outbound_tag.into(),
}
}
pub fn generate_config<C>(
&self,
request: SingBoxGenerationRequest<'_>,
checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where
C: SingBoxConfigChecker,
{
let target = find_local_singbox_target(request.targets)?;
ensure_local_singbox_target(target, request.components)?;
let config = SingBoxConfig {
log: SingBoxLog {
disabled: false,
level: self.log_level.clone(),
timestamp: true,
},
inbounds: vec![SingBoxInbound {
inbound_type: "mixed".to_string(),
tag: self.inbound_tag.clone(),
listen: target.host.clone(),
listen_port: target.port,
users: Vec::new(),
set_system_proxy: false,
}],
outbounds: vec![SingBoxOutbound {
outbound_type: "direct".to_string(),
tag: self.outbound_tag.clone(),
}],
route: SingBoxRoute {
final_outbound: self.outbound_tag.clone(),
},
};
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::Serialization,
format!("Не удалось сериализовать конфиг sing-box: {error}"),
)
})?;
let check = match request.binary_path {
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
None => None,
};
Ok(SingBoxGeneratedConfig {
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents,
local_target_id: target.id.clone(),
listen: target.host.clone(),
listen_port: target.port,
check,
})
}
}
impl Default for SingBoxAdapter {
fn default() -> Self {
Self::new(
"info",
DEFAULT_MIXED_INBOUND_TAG,
DEFAULT_DIRECT_OUTBOUND_TAG,
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SingBoxGenerationRequest<'a> {
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
pub binary_path: Option<&'a Path>,
}
impl<'a> SingBoxGenerationRequest<'a> {
pub fn new(
targets: &'a [Target],
components: &'a [ComponentStatus],
binary_path: Option<&'a Path>,
) -> Self {
Self {
targets,
components,
binary_path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub local_target_id: String,
pub listen: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxCheckResult {
pub checked: bool,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxConfigError {
pub kind: SingBoxConfigErrorKind,
pub message: String,
}
impl SingBoxConfigError {
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SingBoxConfigErrorKind {
MissingLocalTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTarget,
Serialization,
CheckFailed,
}
pub trait SingBoxConfigChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SingBoxCommandChecker;
impl SingBoxConfigChecker for SingBoxCommandChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!(
"vpn-proxy-sing-box-{}-{}.json",
std::process::id(),
now_millis()
));
fs::write(&config_path, config_json).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let output = Command::new(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось выполнить '{} check': {error}",
binary_path.display()
),
)
})?;
let _ = fs::remove_file(&config_path);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let message = command_message(&stdout, &stderr);
if !output.status.success() {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"),
));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: if message.is_empty() {
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxConfig {
pub log: SingBoxLog,
pub inbounds: Vec<SingBoxInbound>,
pub outbounds: Vec<SingBoxOutbound>,
pub route: SingBoxRoute,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxLog {
pub disabled: bool,
pub level: String,
pub timestamp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxInbound {
#[serde(rename = "type")]
pub inbound_type: String,
pub tag: String,
pub listen: String,
#[serde(rename = "listen_port")]
pub listen_port: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<SingBoxUser>,
#[serde(rename = "set_system_proxy")]
pub set_system_proxy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxUser {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxOutbound {
#[serde(rename = "type")]
pub outbound_type: String,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxRoute {
#[serde(rename = "final")]
pub final_outbound: String,
}
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
targets
.iter()
.find(|target| {
target.kind == TargetKind::Local
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingLocalTarget,
"Локальная цель, требующая sing-box, не настроена",
)
})
}
fn ensure_local_singbox_target(
target: &Target,
components: &[ComponentStatus],
) -> Result<(), SingBoxConfigError> {
if target.kind != TargetKind::Local
|| target.protocol != ProxyProtocol::Socks5
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
{
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedTarget,
format!(
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
target.id
),
));
}
let Some(status) = components
.iter()
.find(|component| component.id == ComponentId::Singbox)
else {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingRequiredComponent,
format!(
"Локальная цель '{}' требует состояние компонента sing-box",
target.id
),
));
};
if !component_is_running(status) {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::RequiredComponentNotRunning,
format!(
"Локальная цель '{}' требует установленный и запущенный sing-box",
target.id
),
));
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,413 +0,0 @@
use crate::models::{ComponentId, ComponentState, ComponentStatus};
use serde::Deserialize;
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyfierEngine {
ProxiFyre,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedProxyfier {
pub engine: ProxyfierEngine,
pub name: String,
pub install_dir: PathBuf,
pub executable_path: PathBuf,
pub config_path: Option<PathBuf>,
pub running: bool,
pub service_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryInstallEntry {
pub display_name: String,
pub install_location: Option<PathBuf>,
pub display_icon: Option<PathBuf>,
}
pub trait ProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String>;
fn path_exists(&self, path: &Path) -> bool;
fn process_running(&self, process_name: &str) -> bool;
fn service_running(&self, service_name: &str) -> bool;
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemProxyfierDetectionHost;
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn path_exists(&self, path: &Path) -> bool {
path.exists()
}
fn process_running(&self, process_name: &str) -> bool {
let process_name = process_name.trim_end_matches(".exe");
let script = format!(
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(process_name)
);
powershell_bool(&script)
}
fn service_running(&self, service_name: &str) -> bool {
let script = format!(
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(service_name)
);
powershell_bool(&script)
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries()
}
}
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
}
pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
let proxifyre_running = host.process_running("ProxiFyre.exe")
|| host.service_running("ProxiFyreService")
|| host.service_running("ProxiFyre");
proxyfier_candidates(host)
.into_iter()
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
.next()
}
pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus {
match detected {
Some(proxyfier) => detected_proxyfier_component(proxyfier),
None => missing_proxyfier_component(),
}
}
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
let state = if proxyfier.running {
ComponentState::Running
} else {
ComponentState::Installed
};
let actions = match proxyfier.engine {
ProxyfierEngine::ProxiFyre => {
if proxyfier.running {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Остановить".to_string(),
]
} else {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Запустить".to_string(),
]
}
}
};
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state,
installed: true,
running: proxyfier.running,
version: Some(match proxyfier.engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
}),
path: Some(proxyfier.install_dir.display().to_string()),
problems: Vec::new(),
actions,
}
}
fn missing_proxyfier_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyfierCandidate {
engine: ProxyfierEngine,
name: String,
install_dir: PathBuf,
}
impl ProxyfierCandidate {
fn into_detected(
self,
host: &impl ProxyfierDetectionHost,
proxifyre_running: bool,
) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir);
let exists = host.path_exists(&self.install_dir)
|| host.path_exists(&executable_path)
|| config_path
.as_ref()
.is_some_and(|path| host.path_exists(path));
if !exists {
return None;
}
Some(DetectedProxyfier {
service_name: service_name(&self.engine).map(str::to_string),
engine: self.engine,
name: self.name,
install_dir: self.install_dir,
executable_path,
config_path,
running: proxifyre_running,
})
}
}
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
let mut candidates = Vec::new();
push_env_candidate(
&mut candidates,
host,
ProxyfierEngine::ProxiFyre,
"ProxiFyre",
"VPN_PROXY_PROXIFYRE_ROOT",
);
for entry in host.registry_install_entries() {
if let Some(engine) = engine_from_name(&entry.display_name) {
let install_dir = entry
.install_location
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
if let Some(install_dir) = install_dir {
push_candidate(
&mut candidates,
ProxyfierCandidate {
name: entry.display_name,
engine,
install_dir,
},
);
}
}
}
for install_dir in common_install_dirs(host, "ProxiFyre") {
push_candidate(
&mut candidates,
ProxyfierCandidate {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir,
},
);
}
candidates
}
fn push_env_candidate(
candidates: &mut Vec<ProxyfierCandidate>,
host: &impl ProxyfierDetectionHost,
engine: ProxyfierEngine,
name: &str,
env_name: &str,
) {
if let Some(path) = host.env_var(env_name) {
push_candidate(
candidates,
ProxyfierCandidate {
engine,
name: name.to_string(),
install_dir: PathBuf::from(path),
},
);
}
}
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
if !candidates.iter().any(|existing| {
existing.engine == candidate.engine
&& same_path(&existing.install_dir, &candidate.install_dir)
}) {
candidates.push(candidate);
}
}
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) {
dirs.push(PathBuf::from(root).join(folder_name));
}
}
dirs
}
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
match engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
}
}
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
match engine {
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
}
}
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
match engine {
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
}
}
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
let normalized = name.to_ascii_lowercase();
if normalized.contains("proxifyre") {
Some(ProxyfierEngine::ProxiFyre)
} else {
None
}
}
fn executable_parent(path: &Path) -> Option<PathBuf> {
path.parent().map(Path::to_path_buf)
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
fn powershell_bool(script: &str) -> bool {
Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RegistryInstallJson {
display_name: Option<String>,
install_location: Option<String>,
display_icon: Option<String>,
}
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
let script = r#"
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$items = foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
}
$items |
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
Select-Object DisplayName,InstallLocation,DisplayIcon |
ConvertTo-Json -Compress
"#;
let Ok(output) = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return Vec::new();
};
let stdout = stdout.trim();
if stdout.is_empty() {
return Vec::new();
}
parse_registry_json(stdout)
}
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
match value {
serde_json::Value::Array(entries) => entries
.into_iter()
.filter_map(registry_entry_from_value)
.collect(),
entry => registry_entry_from_value(entry).into_iter().collect(),
}
}
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
let display_name = parsed.display_name?;
Some(RegistryInstallEntry {
display_name,
install_location: parsed
.install_location
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from),
display_icon: parsed
.display_icon
.and_then(|value| display_icon_path(&value)),
})
}
fn display_icon_path(value: &str) -> Option<PathBuf> {
let trimmed = value.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
let without_icon_index = trimmed
.split_once(',')
.map(|(path, _)| path)
.unwrap_or(trimmed)
.trim()
.trim_matches('"');
Some(PathBuf::from(without_icon_index))
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}

View File

@@ -1,184 +0,0 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}

View File

@@ -1,5 +0,0 @@
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -1,46 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod activity;
mod commands;
mod component_detection;
mod models;
mod storage;
mod validation;
mod adapters {
pub mod proxifyre;
pub mod proxy_router;
}
#[cfg(test)]
pub(crate) mod proxifyre {
pub use crate::adapters::proxifyre::*;
}
#[cfg(test)]
pub(crate) mod proxy_router {
pub use crate::adapters::proxy_router::*;
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_profiles,
commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components,
commands::resolve_profile_preview,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service,
commands::stop_proxifyre_service
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -1,167 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProfileItemType {
Process,
Folder,
Exe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TargetKind {
Local,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyProtocol {
Socks5,
Http,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
ControlApp,
Proxyfier,
Singbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ComponentState {
Installed,
Missing,
Stopped,
Running,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInput {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_target_id")]
pub target_id: String,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub items: Vec<ProfileItemInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItem {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_target_kind")]
pub kind: String,
#[serde(default = "default_proxy_protocol")]
pub protocol: String,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentStatus {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
pub version: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry {
pub id: String,
pub at: String,
pub level: ActivityLevel,
pub title: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActivityLevel {
Info,
Warning,
Error,
Success,
}
fn default_enabled() -> bool {
true
}
fn default_target_id() -> String {
"local-singbox".to_string()
}
fn default_protocols() -> Vec<String> {
vec!["TCP".to_string(), "UDP".to_string()]
}
fn default_target_kind() -> String {
"external".to_string()
}
fn default_proxy_protocol() -> String {
"socks5".to_string()
}

View File

@@ -1,187 +0,0 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
pub fn default_config_root() -> PathBuf {
PathBuf::from(r"C:\ProgramData\VpnProxy")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePaths {
pub root: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub generated_dir: PathBuf,
pub profiles_file: PathBuf,
pub targets_file: PathBuf,
pub components_file: PathBuf,
pub activity_file: PathBuf,
}
impl StoragePaths {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config_dir = root.join("config");
let state_dir = root.join("state");
let generated_dir = root.join("generated");
Self {
root,
profiles_file: config_dir.join("profiles.json"),
targets_file: config_dir.join("targets.json"),
components_file: config_dir.join("components.json"),
activity_file: state_dir.join("activity.json"),
config_dir,
state_dir,
generated_dir,
}
}
}
impl Default for StoragePaths {
fn default() -> Self {
Self::new(default_config_root())
}
}
#[derive(Debug, Clone)]
pub struct JsonStorage {
paths: StoragePaths,
activity_limit: usize,
}
impl JsonStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
}
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
Self {
paths: StoragePaths::new(root),
activity_limit,
}
}
pub fn paths(&self) -> &StoragePaths {
&self.paths
}
pub fn ensure_dirs(&self) -> io::Result<()> {
fs::create_dir_all(&self.paths.config_dir)?;
fs::create_dir_all(&self.paths.state_dir)?;
fs::create_dir_all(&self.paths.generated_dir)?;
Ok(())
}
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
self.read_json_or_default(&self.paths.profiles_file)
}
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
self.write_json(&self.paths.profiles_file, profiles)
}
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
self.read_json_or_default(&self.paths.targets_file)
}
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
self.write_json(&self.paths.targets_file, targets)
}
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
self.read_json_or_default(&self.paths.components_file)
}
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
self.write_json(&self.paths.components_file, components)
}
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_json_or_default(&self.paths.activity_file)?;
Ok(cap_activity(entries, self.activity_limit))
}
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
let entries = cap_activity(entries.to_vec(), self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)
}
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_activity()?;
let entries = append_activity(entries, entry, self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)?;
Ok(entries)
}
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
where
T: DeserializeOwned + Default,
{
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(_) => Ok(T::default()),
},
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
}
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
where
T: Serialize + ?Sized,
{
let contents = serde_json::to_vec_pretty(value)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
write_atomic(path, &contents)
}
}
impl Default for JsonStorage {
fn default() -> Self {
Self::new(default_config_root())
}
}
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "tmp")
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage.json");
path.with_file_name(format!("{file_name}.{suffix}"))
}
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
Err(error)
}
}
}

View File

@@ -1,223 +0,0 @@
use crate::models::{
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
Target, TargetInput, TargetKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
ValidationError {
field: field.into(),
message: message.into(),
}
}
fn clean(value: &str) -> String {
value.trim().to_string()
}
fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch);
previous_dash = false;
} else if !previous_dash {
output.push('-');
previous_dash = true;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
fallback.to_string()
} else {
output
}
}
fn process_name(value: &str) -> String {
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
base.strip_suffix(".exe")
.or_else(|| base.strip_suffix(".EXE"))
.unwrap_or(base)
.trim()
.to_string()
}
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Ok(Protocol::Tcp),
"UDP" => Ok(Protocol::Udp),
_ => Err(error(
"protocols",
format!("Неподдерживаемый протокол: {value}"),
)),
}
}
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"process" => Ok(ProfileItemType::Process),
"folder" => Ok(ProfileItemType::Folder),
"exe" => Ok(ProfileItemType::Exe),
_ => Err(error(
"items.type",
format!("Неподдерживаемый тип элемента: {value}"),
)),
}
}
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"local" => Ok(TargetKind::Local),
"external" => Ok(TargetKind::External),
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
}
}
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"socks5" => Ok(ProxyProtocol::Socks5),
"http" => Ok(ProxyProtocol::Http),
_ => Err(error(
"protocol",
format!("Неподдерживаемый протокол прокси: {value}"),
)),
}
}
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
"proxyfier" => Ok(ComponentId::Proxyfier),
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
_ => Err(error(
"requires_component",
format!("Неподдерживаемый компонент: {value}"),
)),
}
}
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
let mut errors = Vec::new();
let name = clean(&input.name);
if name.is_empty() {
errors.push(error("name", "Укажите название профиля"));
}
let target_id = clean(&input.target_id);
if target_id.is_empty() {
errors.push(error("target_id", "Укажите цель профиля"));
}
let mut protocols = Vec::new();
for value in input.protocols {
match parse_protocol(&value) {
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
Ok(_) => {}
Err(err) => errors.push(err),
}
}
if protocols.is_empty() {
errors.push(error("protocols", "Выберите хотя бы один протокол"));
}
let mut items = Vec::new();
for raw_item in input.items {
let item_type = match parse_profile_item_type(&raw_item.item_type) {
Ok(item_type) => item_type,
Err(err) => {
errors.push(err);
continue;
}
};
let value = match item_type {
ProfileItemType::Process => process_name(&raw_item.value),
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
};
if value.is_empty() {
errors.push(error("items.value", "Укажите значение элемента профиля"));
continue;
}
let recursive =
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
items.push(ProfileItem {
item_type,
value,
recursive,
});
}
if !errors.is_empty() {
return Err(errors);
}
Ok(Profile {
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
name,
enabled: input.enabled,
target_id,
protocols,
items,
})
}
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
let mut errors = Vec::new();
let name = clean(&input.name);
let host = clean(&input.host);
if name.is_empty() {
errors.push(error("name", "Укажите название цели"));
}
if host.is_empty() {
errors.push(error("host", "Укажите хост цели"));
}
if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
}
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
errors.push(err);
TargetKind::External
});
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
errors.push(err);
ProxyProtocol::Socks5
});
let requires_component = match input.requires_component {
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
Ok(component) => Some(component),
Err(err) => {
errors.push(err);
None
}
},
_ => None,
};
if !errors.is_empty() {
return Err(errors);
}
Ok(Target {
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
name,
kind,
protocol,
host,
port: input.port as u16,
requires_component,
})
}

View File

@@ -1,37 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VPN Proxy для Windows",
"version": "0.1.0",
"identifier": "ru.dokops.vpn-proxy.windows",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "VPN Proxy для Windows",
"width": 1120,
"height": 760,
"minWidth": 760,
"minHeight": 560,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}

View File

@@ -1,447 +0,0 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/commands.rs"]
mod commands;
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/storage.rs"]
mod storage;
#[path = "../src/validation.rs"]
mod validation;
use commands::{
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
TargetInputDto,
};
use component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
};
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::ProxiFyreAdapter;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test]
fn save_commands_normalize_and_persist_profile_and_target() {
let root = test_root("save");
let storage = JsonStorage::new(root.clone());
let target = save_target_to_storage(
&storage,
TargetInputDto {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: Some("external".to_string()),
protocol: Some("socks5".to_string()),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
},
)
.expect("target command should normalize");
let profile = save_profile_to_storage(
&storage,
ProfileInputDto {
id: Some("Discord".to_string()),
name: " Discord ".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]),
items: Some(vec![ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
}]),
},
)
.expect("profile command should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(profile.id, "discord");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.items[0].value, "Discord");
let status = build_status(&storage).expect("status command should read stored state");
assert_eq!(
status.route_line,
"Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080"
);
assert_eq!(status.active_profile_count, 1);
assert_eq!(status.routed_app_count, 1);
cleanup(&root);
}
#[test]
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
let preview = resolve_preview(ProfileInputDto {
id: Some("Game".to_string()),
name: "Game".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["TCP".to_string()]),
items: Some(vec![
ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInputDto {
item_type: "folder".to_string(),
value: r"C:\Games\Launcher".to_string(),
recursive: Some(true),
},
]),
})
.expect("preview should normalize profile input");
assert_eq!(preview.profile_id, "game");
assert_eq!(preview.apps.len(), 2);
assert_eq!(preview.apps[0].app_name, "Discord");
assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder);
assert!(preview
.warnings
.iter()
.any(|warning| warning.contains("Сканирование папок отложено")));
}
#[test]
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
let root = test_root("apply");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("home-gateway")])
.expect("write profiles");
storage
.write_targets(&[external_socks5_target()])
.expect("write targets");
storage
.write_components(&[proxyfier_running(), singbox_missing()])
.expect("write components");
let response = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect("apply command should generate config and call helper");
let generated_path = PathBuf::from(&response.generated_config_path);
let generated_contents = fs::read_to_string(&generated_path).expect("read generated config");
let activity = storage.read_activity().expect("read activity");
assert!(response.success);
assert!(response.changed);
assert_eq!(response.adapter_id, "proxifyre");
assert_eq!(response.enabled_profiles, 1);
assert_eq!(response.routed_apps, 1);
assert_eq!(response.helper.action, "proxyfier.apply.mock");
assert!(generated_contents.contains("\"appNames\""));
assert!(generated_contents.contains("Discord"));
assert!(generated_path.ends_with("proxifyre-app-config.json"));
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
cleanup(&root);
}
#[test]
fn apply_blocks_local_singbox_target_when_component_is_missing() {
let root = test_root("missing-singbox");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("local-singbox")])
.expect("write profiles");
storage
.write_targets(&[local_singbox_target()])
.expect("write targets");
storage
.write_components(&[singbox_missing()])
.expect("write components");
let error = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect_err("missing sing-box should block local target apply");
let activity = storage.read_activity().expect("read blocked activity");
assert_eq!(error.code, "required_component_not_running");
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].level, models::ActivityLevel::Error);
assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано");
cleanup(&root);
}
#[test]
fn component_status_merges_detected_existing_proxifyre() {
let components = resolve_component_statuses(
Vec::new(),
Some(DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true,
service_name: Some("ProxiFyreService".to_string()),
}),
);
let proxyfier = components
.iter()
.find(|component| component.id == ComponentId::Proxyfier)
.expect("proxyfier component");
assert_eq!(proxyfier.state, ComponentState::Running);
assert!(proxyfier.installed);
assert!(proxyfier.running);
assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(proxyfier.problems.is_empty());
}
#[test]
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let root = test_root("detected-proxifyre");
let install_dir = root.join("ProxiFyre");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("ProxiFyre", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("ProxiFyre.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("detected helper should apply");
let applied =
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#);
assert!(install_dir.join("app-config.json.bak").exists());
cleanup(&root);
}
#[test]
fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
let root = test_root("detected-proxifier");
let install_dir = root.join("Proxifier");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("Proxifier", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("Proxifier.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("plain Proxifier should be ignored and config should be staged");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.stage-generated-config");
assert!(result
.message
.contains("совместимая установка ProxiFyre не найдена"));
cleanup(&root);
}
struct MockApplyHelper;
impl ProxyApplyHelper for MockApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
assert_eq!(request.adapter_id, "proxifyre");
assert!(request.config_contents.contains("Discord"));
assert!(request.config_path.ends_with("proxifyre-app-config.json"));
Ok(HelperApplyResult {
success: true,
changed: true,
action: "proxyfier.apply.mock".to_string(),
message: "Mock helper accepted generated ProxiFyre config".to_string(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now(&self) -> String {
"2026-07-03T00:00:00Z".to_string()
}
}
#[derive(Default)]
struct DetectionHost {
paths: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl DetectionHost {
fn new() -> Self {
Self::default()
}
fn with_path(mut self, path: &Path) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(install_location.to_path_buf()),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for DetectionHost {
fn env_var(&self, _name: &str) -> Option<String> {
None
}
fn path_exists(&self, path: &Path) -> bool {
self.paths.contains(&normalize_path(path))
}
fn process_running(&self, _process_name: &str) -> bool {
false
}
fn service_running(&self, _service_name: &str) -> bool {
false
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &Path) -> String {
path.display()
.to_string()
.replace('/', "\\")
.to_ascii_lowercase()
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("vpn-proxy-commands-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Домашний шлюз".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn proxyfier_running() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string()],
}
}
fn singbox_missing() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()],
}
}

View File

@@ -1,146 +0,0 @@
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
use component_detection::{
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry,
};
use models::ComponentState;
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
#[test]
fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre")
.with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host)
.expect("existing ProxiFyre install should be detected");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
assert_eq!(
detected.config_path,
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
);
assert!(detected.running);
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn ignores_plain_proxifier_install() {
let host = MockHost::new()
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
.with_path(r"C:\Program Files\Proxifier")
.with_process("Proxifier.exe");
assert!(detect_proxyfier_install_with_host(&host).is_none());
}
#[test]
fn env_override_can_point_to_portable_proxifyre_install() {
let host = MockHost::new()
.with_env("VPN_PROXY_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
let detected = detect_proxyfier_install_with_host(&host)
.expect("env override should be checked before common paths");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(
detected.executable_path,
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
);
}
#[test]
fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
}
#[derive(Default)]
struct MockHost {
env: HashMap<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl MockHost {
fn new() -> Self {
Self::default()
}
fn with_env(mut self, name: &str, value: &str) -> Self {
self.env.insert(name.to_string(), value.to_string());
self
}
fn with_path(mut self, path: &str) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_process(mut self, process: &str) -> Self {
self.processes.insert(process.to_ascii_lowercase());
self
}
fn with_service(mut self, service: &str) -> Self {
self.services.insert(service.to_ascii_lowercase());
self
}
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(PathBuf::from(install_location)),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for MockHost {
fn env_var(&self, name: &str) -> Option<String> {
self.env.get(name).cloned()
}
fn path_exists(&self, path: &Path) -> bool {
self.paths
.contains(&normalize_path(&path.display().to_string()))
}
fn process_running(&self, process_name: &str) -> bool {
self.processes.contains(&process_name.to_ascii_lowercase())
}
fn service_running(&self, service_name: &str) -> bool {
self.services.contains(&service_name.to_ascii_lowercase())
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}

View File

@@ -1,128 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/validation.rs"]
mod validation;
use models::{
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
TargetInput, TargetKind,
};
use validation::{normalize_profile, normalize_target};
#[test]
fn normalizes_profile_source_items() {
let profile = normalize_profile(ProfileInput {
id: Some("Discord + Vesktop".to_string()),
name: " Discord + Vesktop ".to_string(),
enabled: true,
target_id: " home-gateway ".to_string(),
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
items: vec![
ProfileItemInput {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInput {
item_type: "folder".to_string(),
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
recursive: Some(true),
},
ProfileItemInput {
item_type: "exe".to_string(),
value: "C:\\Games\\Game\\game.exe".to_string(),
recursive: Some(true),
},
],
})
.expect("profile should normalize");
assert_eq!(profile.id, "discord-vesktop");
assert_eq!(profile.name, "Discord + Vesktop");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
assert_eq!(profile.items[0].value, "Discord");
assert!(!profile.items[0].recursive);
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
assert!(profile.items[1].recursive);
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
assert!(!profile.items[2].recursive);
}
#[test]
fn rejects_unsupported_profile_protocols() {
let error = normalize_profile(ProfileInput {
id: None,
name: "Bad protocol".to_string(),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec!["icmp".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "Discord".to_string(),
recursive: None,
}],
})
.expect_err("unsupported protocol should fail");
assert!(error.iter().any(|item| item.field == "protocols"));
}
#[test]
fn normalizes_external_target_without_local_singbox() {
let target = normalize_target(TargetInput {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
})
.expect("external target should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(target.kind, TargetKind::External);
assert_eq!(target.protocol, ProxyProtocol::Socks5);
assert_eq!(target.host, "192.168.50.111");
assert_eq!(target.port, 8080);
assert_eq!(target.requires_component, None);
}
#[test]
fn local_singbox_target_can_exist_before_component_is_installed() {
let target = normalize_target(TargetInput {
id: Some("local-singbox".to_string()),
name: "Local sing-box".to_string(),
kind: "local".to_string(),
protocol: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some("singbox".to_string()),
})
.expect("local target definition should not require installed component");
assert_eq!(target.kind, TargetKind::Local);
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
}
#[test]
fn rejects_malformed_target_fields() {
let error = normalize_target(TargetInput {
id: None,
name: "".to_string(),
kind: "external".to_string(),
protocol: "ftp".to_string(),
host: "".to_string(),
port: 70_000,
requires_component: Some("unknown".to_string()),
})
.expect_err("invalid target should fail");
assert!(error.iter().any(|item| item.field == "name"));
assert!(error.iter().any(|item| item.field == "host"));
assert!(error.iter().any(|item| item.field == "port"));
assert!(error.iter().any(|item| item.field == "protocol"));
assert!(error.iter().any(|item| item.field == "requires_component"));
}

View File

@@ -1,139 +0,0 @@
#[path = "../src/helper.rs"]
mod helper;
#[path = "../src/models.rs"]
mod models;
use helper::{
helper_action_requires_elevation, install_request, parse_helper_response,
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
};
use models::ComponentId;
use serde_json::json;
use std::cell::RefCell;
use std::path::PathBuf;
#[test]
fn structured_helper_serializes_request_and_parses_json_response() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: serde_json::to_string(&HelperResponse {
success: true,
action: HelperAction::ProxyfierApply,
changed: true,
message: "Applied".to_string(),
details: json!({ "serviceName": "ProxiFyreService" }),
})
.expect("response json"),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let response = helper
.execute(&proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
))
.expect("helper response");
assert!(response.success);
assert_eq!(response.action, HelperAction::ProxyfierApply);
assert_eq!(response.details["serviceName"], "ProxiFyreService");
}
#[test]
fn helper_runner_receives_json_stdin_and_elevation_flag() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
let _ = helper.execute(&request).expect("helper response");
let seen = helper.runner().seen.borrow();
let spec = seen.first().expect("runner should be called");
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
assert_eq!(spec.program, PathBuf::from("vpn-proxy-helper.exe"));
assert_eq!(spec.args, vec!["--json"]);
assert!(spec.requires_elevation);
assert_eq!(stdin["action"], "service.restart");
assert_eq!(stdin["component"], "proxyfier");
}
#[test]
fn install_requests_are_explicit_component_actions() {
let control = install_request(ComponentId::ControlApp);
let proxyfier = install_request(ComponentId::Proxyfier);
let singbox = install_request(ComponentId::Singbox);
assert_eq!(control.action, HelperAction::InstallControlApp);
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
assert_eq!(singbox.action, HelperAction::InstallSingbox);
assert!(helper_action_requires_elevation(&proxyfier.action));
}
#[test]
fn apply_request_does_not_encode_installer_action() {
let request = proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
);
assert_eq!(request.action, HelperAction::ProxyfierApply);
assert_eq!(request.component, Some(ComponentId::Proxyfier));
assert_eq!(
request.payload["configPath"],
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json"
);
}
#[test]
fn non_json_helper_stdout_is_rejected() {
let error = parse_helper_response("Proxyfier restarted successfully")
.expect_err("raw stdout should not be accepted");
assert_eq!(error.code, "helper_response_decode");
}
#[test]
fn failed_helper_exit_is_structured_error() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 5,
stdout: String::new(),
stderr: "Access denied".to_string(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let error = helper
.execute(&service_request(
ComponentId::Proxyfier,
HelperAction::ServiceRestart,
))
.expect_err("failed exit should become helper error");
assert_eq!(error.code, "helper_exit");
assert!(error.message.contains("Access denied"));
}
struct MockRunner {
output: HelperCommandOutput,
seen: RefCell<Vec<HelperCommandSpec>>,
}
impl HelperCommandRunner for MockRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
self.seen.borrow_mut().push(spec.clone());
Ok(self.output.clone())
}
}

View File

@@ -1,207 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
#[test]
fn generates_proxifyre_config_for_discord_external_socks5_target() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external socks5 target should not require sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.adapter_id, "proxifyre");
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
assert_eq!(generated.enabled_profiles, 1);
assert_eq!(generated.routed_apps, 1);
assert_eq!(config.log_level, "Info");
assert!(config.bypass_lan);
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
}
#[test]
fn skips_disabled_profiles_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut disabled = discord_profile("home-gateway");
disabled.enabled = false;
let profiles = vec![disabled];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("disabled profiles should produce empty config");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.enabled_profiles, 0);
assert_eq!(generated.routed_apps, 0);
assert!(config.proxies.is_empty());
}
#[test]
fn includes_folder_paths_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut profile = discord_profile("home-gateway");
profile.items.push(ProfileItem {
item_type: ProfileItemType::Folder,
value: r"C:\Games\MyGame".to_string(),
recursive: true,
});
let profiles = vec![profile];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("folder paths should be accepted by ProxiFyre config generation");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.routed_apps, 2);
assert_eq!(
config.proxies[0].app_names,
vec!["Discord", r"C:\Games\MyGame"]
);
}
#[test]
fn blocks_local_singbox_target_when_required_component_is_missing() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("local sing-box target should require installed running sing-box");
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
}
#[test]
fn local_singbox_target_generates_when_required_component_is_running() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("running sing-box should satisfy local target dependency");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
}
#[test]
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("office-http")];
let targets = vec![Target {
id: "office-http".to_string(),
name: "Office HTTP".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Http,
host: "192.168.50.111".to_string(),
port: 3128,
requires_component: None,
}];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("ProxiFyre should reject HTTP targets");
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}

View File

@@ -1,284 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use singbox::{
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
};
use std::{
cell::RefCell,
path::{Path, PathBuf},
};
#[test]
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("configuration OK");
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
&checker,
)
.expect("running local sing-box should generate config");
let config: SingBoxConfig =
serde_json::from_str(&generated.contents).expect("generated sing-box json");
assert_eq!(generated.adapter_id, "singbox");
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
assert_eq!(generated.local_target_id, "local-singbox");
assert_eq!(generated.listen, "127.0.0.1");
assert_eq!(generated.listen_port, 1080);
assert_eq!(
generated.check,
Some(SingBoxCheckResult {
checked: true,
success: true,
message: "configuration OK".to_string(),
})
);
assert_eq!(config.log.level, "info");
assert_eq!(config.inbounds.len(), 1);
assert_eq!(config.inbounds[0].inbound_type, "mixed");
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
assert_eq!(config.inbounds[0].listen_port, 1080);
assert!(!config.inbounds[0].set_system_proxy);
assert_eq!(config.outbounds[0].outbound_type, "direct");
assert_eq!(config.route.final_outbound, "direct");
let calls = checker.calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_path(), binary_path);
assert!(calls[0].1.contains(r#""type": "mixed""#));
}
#[test]
fn skips_singbox_check_when_binary_path_is_not_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect("binary path is optional");
assert_eq!(generated.check, None);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_required_component_is_missing() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = Vec::new();
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires component state");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_component_is_not_running() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![stopped_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires running component");
assert_eq!(
error.kind,
SingBoxConfigErrorKind::RequiredComponentNotRunning
);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn propagates_failed_singbox_check_as_structured_error() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::err("invalid config");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
&checker,
)
.expect_err("failed sing-box check should block generated config");
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
assert!(error.message.contains("invalid config"));
}
#[test]
fn external_proxifyre_apply_does_not_require_singbox_component() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external SOCKS5 target should not require local sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
}
struct RecordingChecker {
calls: RefCell<Vec<(PathBuf, String)>>,
result: Result<SingBoxCheckResult, SingBoxConfigError>,
}
impl RecordingChecker {
fn ok(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Ok(SingBoxCheckResult {
checked: true,
success: true,
message: message.to_string(),
}),
}
}
fn err(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
message,
)),
}
}
}
impl SingBoxConfigChecker for RecordingChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
self.calls
.borrow_mut()
.push((binary_path.to_path_buf(), config_json.to_string()));
self.result.clone()
}
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}
fn stopped_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Stopped,
installed: true,
running: false,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: vec!["Service is stopped".to_string()],
actions: vec!["Start".to_string()],
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}

View File

@@ -1,198 +0,0 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/storage.rs"]
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
#[test]
fn storage_defaults_to_programdata_root() {
let expected = PathBuf::from(r"C:\ProgramData\VpnProxy");
assert_eq!(default_config_root(), expected);
assert_eq!(StoragePaths::default().root, expected);
}
#[test]
fn roundtrips_profiles_targets_components_and_activity() {
let root = test_root("roundtrip");
let storage = JsonStorage::new(root.clone());
let profiles = vec![sample_profile("discord")];
let targets = vec![sample_target("home-gateway")];
let components = vec![sample_component()];
let activity = vec![sample_activity(
"created",
"2026-01-01T10:00:00Z",
ActivityLevel::Success,
)];
storage.write_profiles(&profiles).expect("write profiles");
storage.write_targets(&targets).expect("write targets");
storage
.write_components(&components)
.expect("write components");
storage.write_activity(&activity).expect("write activity");
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
assert_eq!(storage.read_targets().expect("read targets"), targets);
assert_eq!(
storage.read_components().expect("read components"),
components
);
assert_eq!(storage.read_activity().expect("read activity"), activity);
cleanup(&root);
}
#[test]
fn invalid_json_falls_back_to_empty_collection() {
let root = test_root("invalid-json");
let storage = JsonStorage::new(root.clone());
storage.ensure_dirs().expect("create storage dirs");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"),
Vec::<Profile>::new()
);
cleanup(&root);
}
#[test]
fn write_creates_backup_before_overwriting_source_file() {
let root = test_root("backup");
let storage = JsonStorage::new(root.clone());
let first = vec![sample_profile("first")];
let second = vec![sample_profile("second")];
storage.write_profiles(&first).expect("first write");
storage.write_profiles(&second).expect("second write");
let backup = backup_path(&storage.paths().profiles_file);
assert!(backup.exists(), "backup file should exist");
let backup_contents = fs::read_to_string(backup).expect("read backup");
let backup_profiles: Vec<Profile> =
serde_json::from_str(&backup_contents).expect("backup json");
assert_eq!(backup_profiles, first);
assert_eq!(storage.read_profiles().expect("current profiles"), second);
cleanup(&root);
}
#[test]
fn activity_entries_are_sorted_and_capped() {
let root = test_root("activity");
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
storage
.append_activity(sample_activity(
"old",
"2026-01-01T10:00:00Z",
ActivityLevel::Info,
))
.expect("append old");
storage
.append_activity(sample_activity(
"new",
"2026-01-03T10:00:00Z",
ActivityLevel::Success,
))
.expect("append new");
storage
.append_activity(sample_activity(
"middle",
"2026-01-02T10:00:00Z",
ActivityLevel::Warning,
))
.expect("append middle");
let entries = storage.read_activity().expect("read capped activity");
assert_eq!(entries.len(), 2);
assert_eq!(
entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["new", "middle"]
);
cleanup(&root);
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("vpn-proxy-storage-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),
name: format!("Profile {id}"),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn sample_target(id: &str) -> Target {
Target {
id: id.to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn sample_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
ActivityEntry {
id: id.to_string(),
at: at.to_string(),
level,
title: format!("Activity {id}"),
message: "Storage test activity".to_string(),
}
}

View File

@@ -1,97 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import type {
ActivityEntry,
ComponentStatus,
Profile,
ProfileInput,
Target,
TargetInput,
} from '../domain/types';
export interface CommandError {
code: string;
message: string;
details?: Array<{
field: string;
message: string;
}>;
}
export interface StatusResponse {
routeLine: string;
activeProfileCount: number;
routedAppCount: number;
activeTarget?: Target;
components: ComponentStatus[];
recentActivity: ActivityEntry[];
generatedConfigPath: string;
}
export interface SavedStateResponse {
profiles: Profile[];
targets: Target[];
generatedConfigPath: string;
}
export interface HelperApplyResult {
success: boolean;
changed: boolean;
action: string;
message: string;
}
export interface ApplyProfilesResponse {
success: boolean;
changed: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
enabledProfiles: number;
routedApps: number;
helper: HelperApplyResult;
activity: ActivityEntry;
}
export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status');
}
export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state');
}
export function getProfiles(): Promise<Profile[]> {
return invoke<Profile[]>('get_profiles');
}
export function saveProfile(input: ProfileInput): Promise<Profile> {
return invoke<Profile>('save_profile', { input });
}
export function getTargets(): Promise<Target[]> {
return invoke<Target[]>('get_targets');
}
export function saveTarget(input: TargetInput): Promise<Target> {
return invoke<Target>('save_target', { input });
}
export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components');
}
export function applyProfiles(): Promise<ApplyProfilesResponse> {
return invoke<ApplyProfilesResponse>('apply_profiles');
}
export function openConfigLocation(): Promise<string> {
return invoke<string>('open_config_location');
}
export function startProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_proxifyre_service');
}
export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service');
}

View File

@@ -1,770 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen } from 'lucide-react';
import {
applyProfiles,
getComponents,
getSavedState,
openConfigLocation,
saveProfile,
saveTarget,
startProxiFyreService,
stopProxiFyreService,
type ApplyProfilesResponse,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ServiceVisualState = 'active' | 'settling' | null;
interface DraftItem {
id: string;
type: DraftItemType;
value: string;
}
interface Notice {
kind: 'success' | 'error' | 'info';
title: string;
text: string;
}
interface LogEntry extends Notice {
id: string;
at: number;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOG_VISIBLE_MS = 6500;
const fallbackComponents: ComponentStatus[] = [
{
id: 'proxyfier',
name: 'ProxiFyre',
state: 'missing',
installed: false,
running: false,
problems: ['ProxiFyre не найден'],
actions: [],
},
];
export function App() {
const [proxyInput, setProxyInput] = useState('');
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState('');
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
const [isLogOpen, setIsLogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
const [serviceAction, setServiceAction] = useState<'start' | 'stop' | null>(null);
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const proxyfier = useMemo(
() => components.find((component) => component.id === 'proxyfier'),
[components],
);
const activeLog = useMemo(
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
[activeLogId, logEntries],
);
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
const finderVisualClass =
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
return () => {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!activeLogId) return undefined;
const timer = window.setTimeout(() => {
setActiveLogId((current) => (current === activeLogId ? null : current));
}, LOG_VISIBLE_MS);
return () => window.clearTimeout(timer);
}, [activeLogId]);
async function refresh() {
setIsLoading(true);
try {
const saved = await getSavedState();
applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath);
} catch {
showNotice({
kind: 'info',
title: 'Режим предпросмотра',
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
});
} finally {
setIsLoading(false);
}
void refreshComponents();
}
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const detectedComponents = await getComponents();
setComponents(detectedComponents);
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не проверен',
text: errorMessage(error),
});
} finally {
setIsDetectingComponents(false);
}
}
function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) {
const activeProfiles = profiles.filter((profile) => profile.enabled);
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
const activeProfile = mainProfile ?? activeProfiles[0];
const activeTarget = targetForUi(targets, activeProfile);
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
if (activeTarget) setProxyInput(formatProxy(activeTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
setGeneratedConfigPath(generatedPath);
}
function addItem(type: DraftItemType, rawValue: string) {
const value = normalizeItemValue(rawValue, type);
if (!value) {
showNotice({
kind: 'error',
title: 'Нечего добавить',
text: emptyItemMessage(type),
});
return false;
}
if (items.some((item) => item.type === type && sameValue(item.value, value))) {
showNotice({
kind: 'info',
title: 'Уже добавлено',
text: value,
});
return false;
}
setItems((current) => [
...current,
{
id: `${type}-${Date.now()}`,
type,
value,
},
]);
return true;
}
function addProcess() {
if (addItem('process', processInput)) {
setProcessInput('');
setIsProcessInputOpen(false);
}
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
setPickerAction(type);
try {
const selectedPath = await pickPath(type);
if (selectedPath) {
addItem(type, selectedPath);
}
} catch (error) {
showNotice({
kind: 'error',
title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана',
text: errorMessage(error),
});
} finally {
setPickerAction(null);
}
}
async function updateConfig() {
let parsedProxy: ParsedProxy;
try {
parsedProxy = parseProxy(proxyInput);
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
} catch (error) {
showNotice({
kind: 'error',
title: 'Проверь данные',
text: errorMessage(error),
});
return;
}
setIsApplying(true);
try {
await saveTarget({
id: targetId,
name: 'Основной прокси',
kind: 'external',
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
});
await saveProfile({
id: profileId,
name: 'Приложения через прокси',
enabled: true,
targetId,
protocols: ['TCP', 'UDP'],
items: items.map(profileItemInput),
});
await Promise.all(
loadedProfiles
.filter((profile) => profile.enabled && profile.id !== profileId)
.map((profile) => saveProfile(profileInputFromProfile(profile, false))),
);
const result = await applyProfiles();
const [saved, detectedComponents] = await Promise.all([
getSavedState(),
getComponents(),
]);
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
setComponents(detectedComponents);
showNotice(noticeFromApply(result));
} catch (error) {
showNotice({
kind: 'error',
title: 'Конфиг не обновлен',
text: errorMessage(error),
});
} finally {
setIsApplying(false);
}
}
async function openConfig() {
setIsOpeningConfig(true);
try {
const openedPath = await openConfigLocation();
showNotice({
kind: 'info',
title: 'Конфиг открыт',
text: openedPath,
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Не удалось открыть конфиг',
text: errorMessage(error),
});
} finally {
setIsOpeningConfig(false);
}
}
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? 'start' : 'stop';
setServiceAction(action);
startServiceVisual();
try {
await nextFrame();
const component = shouldRun
? await startProxiFyreService()
: await stopProxiFyreService();
setComponents((current) => upsertComponent(current, component));
showNotice({
kind: 'success',
title: shouldRun ? 'Служба запущена' : 'Служба остановлена',
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
function startServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
serviceVisualTimerRef.current = null;
}
setServiceVisualState('active');
}
function settleServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
setServiceVisualState('settling');
serviceVisualTimerRef.current = window.setTimeout(() => {
setServiceVisualState(null);
serviceVisualTimerRef.current = null;
}, 700);
}
function showNotice(notice: Notice) {
const entry: LogEntry = {
...notice,
id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`,
at: Date.now(),
};
setLogEntries((current) => [entry, ...current].slice(0, 40));
setActiveLogId(entry.id);
}
return (
<main className="simple-shell">
<section className="simple-panel">
<header className="simple-header">
<div>
<small>VPN Proxy</small>
<h1>Прокси для приложений</h1>
</div>
<button
type="button"
className="ghost-button"
onClick={refresh}
disabled={isLoading || isDetectingComponents}
>
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
</button>
</header>
<div className={`finder-card ${finderStateClass} ${finderVisualClass}`.trim()}>
<span className="finder-border-glow" aria-hidden="true">
<span className="finder-border-glow-segment top" />
<span className="finder-border-glow-segment right" />
<span className="finder-border-glow-segment bottom" />
<span className="finder-border-glow-segment left" />
</span>
<span className="status-light" />
<div className="finder-text">
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
<button
type="button"
className="service-button"
onClick={() => setProxiFyreServiceRunning(true)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || Boolean(proxyfier?.running)}
>
{serviceAction === 'start' ? '...' : 'Запустить'}
</button>
<button
type="button"
className="service-button stop"
onClick={() => setProxiFyreServiceRunning(false)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || !proxyfier?.running}
>
{serviceAction === 'stop' ? '...' : 'Остановить'}
</button>
</div>
</div>
<label className="simple-field">
<span>Прокси</span>
<input
value={proxyInput}
onChange={(event) => setProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
</label>
<section className="apps-section">
<div className="section-head">
<h2>Приложения</h2>
<span>{items.length}</span>
</div>
{isProcessInputOpen ? (
<div className="process-add-line">
<input
value={processInput}
onChange={(event) => setProcessInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') addProcess();
if (event.key === 'Escape') {
setProcessInput('');
setIsProcessInputOpen(false);
}
}}
placeholder="Discord"
spellCheck={false}
autoFocus
/>
<button type="button" onClick={addProcess}>
OK
</button>
<button
type="button"
className="process-cancel-button"
onClick={() => {
setProcessInput('');
setIsProcessInputOpen(false);
}}
>
Отмена
</button>
</div>
) : (
<div className="add-toolbar" aria-label="Добавить приложение">
<button
type="button"
className="add-tile"
onClick={() => setIsProcessInputOpen(true)}
aria-label="Добавить процесс"
title="Процесс"
>
<Cpu size={22} strokeWidth={1.8} />
<span className="sr-only">Процесс</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('exe')}
disabled={Boolean(pickerAction)}
aria-label="Добавить EXE-файл"
title="EXE-файл"
>
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={22} strokeWidth={1.8} />}
<span className="sr-only">EXE-файл</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('folder')}
disabled={Boolean(pickerAction)}
aria-label="Добавить папку"
title="Папка"
>
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={22} strokeWidth={1.8} />}
<span className="sr-only">Папка</span>
</button>
</div>
)}
<div className="app-list">
{isLoading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</button>
</div>
))
) : (
<div className="empty-state">Добавь процесс, папку или путь к EXE-файлу.</div>
)}
</div>
</section>
<div className="command-row">
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
</button>
<button
type="button"
className="open-config-button"
onClick={openConfig}
disabled={isOpeningConfig}
>
{isOpeningConfig ? '...' : 'Открыть'}
</button>
</div>
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
</section>
{logEntries.length ? (
<aside className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
{activeLog ? (
<>
<strong>{activeLog.title}</strong>
<span>{activeLog.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
<span>{logEntries.length}</span>
</button>
{isLogOpen ? (
<div className="log-history">
{logEntries.map((entry) => (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatLogTime(entry.at)}</time>
<div>
<strong>{entry.title}</strong>
<span>{entry.text}</span>
</div>
</div>
))}
</div>
) : null}
</aside>
) : null}
</main>
);
}
interface ParsedProxy {
protocol: 'socks5';
host: string;
port: number;
}
function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.');
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new Error('Формат: socks5://host:port или host:port.');
}
const protocol = parsed.protocol.replace(':', '').toLowerCase();
if (protocol !== 'socks5') {
throw new Error('Сейчас поддерживается только SOCKS5.');
}
if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
}
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.');
}
return { protocol: 'socks5', host, port };
}
function targetForUi(targets: Target[], profile: Profile | undefined) {
if (profile) return targets.find((target) => target.id === profile.targetId);
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
}
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
const seen = new Set<string>();
const items: DraftItem[] = [];
for (const profile of profiles) {
for (const item of profile.items) {
if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue;
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({
id: `${item.type}-${items.length}-${item.value}`,
type: item.type,
value: item.value,
});
}
}
return items;
}
function formatProxy(target: Target) {
return target.protocol === 'socks5'
? `${target.host}:${target.port}`
: `${target.protocol}://${target.host}:${target.port}`;
}
function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, '');
if (!clean) return '';
if (type === 'folder' || type === 'exe') return clean;
return clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '')
.trim() ?? '';
}
async function pickPath(type: Extract<DraftItemType, 'exe' | 'folder'>) {
const selected = await open(
type === 'folder'
? {
title: 'Выбери папку',
directory: true,
multiple: false,
}
: {
title: 'Выбери EXE-файл',
directory: false,
multiple: false,
filters: [{ name: 'EXE-файлы', extensions: ['exe'] }],
},
);
if (Array.isArray(selected)) return selected[0] ?? null;
return selected;
}
function nextFrame() {
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
function profileItemInput(item: DraftItem): ProfileItemInput {
return {
type: item.type,
value: item.value,
recursive: item.type === 'folder',
};
}
function emptyItemMessage(type: DraftItemType) {
if (type === 'process') return 'Введи имя процесса.';
if (type === 'folder') return 'Введи путь к папке.';
return 'Введи путь к EXE-файлу.';
}
function itemTypeLabel(type: DraftItemType) {
if (type === 'process') return 'процесс';
if (type === 'folder') return 'папка';
return 'EXE-файл';
}
function itemIcon(type: DraftItemType) {
if (type === 'process') return <Cpu size={18} strokeWidth={1.9} />;
if (type === 'folder') return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function profileInputFromProfile(profile: Profile, enabled: boolean) {
return {
id: profile.id,
name: profile.name,
enabled,
targetId: profile.targetId,
protocols: profile.protocols,
items: profile.items.map((item) => ({
type: item.type,
value: item.value,
recursive: item.recursive,
})),
};
}
function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю ProxiFyre';
if (!component) return 'ProxiFyre не проверен';
if (component.running) return 'ProxiFyre найден и запущен';
if (component.installed) return 'ProxiFyre найден';
return 'ProxiFyre не найден';
}
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Ищу установленный клиент и состояние службы.';
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
}
function noticeFromApply(result: ApplyProfilesResponse): Notice {
return {
kind: result.success ? 'success' : 'error',
title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен',
text: result.message,
};
}
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
const index = components.findIndex((current) => current.id === component.id);
if (index === -1) return [...components, component];
return [
...components.slice(0, index),
component,
...components.slice(index + 1),
];
}
function sameValue(left: string, right: string) {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
function formatLogTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
return String((error as { message: unknown }).message);
}
return 'Неизвестная ошибка.';
}

View File

@@ -1,77 +0,0 @@
export type Protocol = 'TCP' | 'UDP';
export type ProfileItemType = 'process' | 'folder' | 'exe';
export type TargetKind = 'local' | 'external';
export type ProxyProtocol = 'socks5' | 'http';
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
export interface ProfileItemInput {
type: ProfileItemType | string;
value: string;
recursive?: boolean;
}
export interface ProfileInput {
id?: string;
name: string;
enabled?: boolean;
targetId?: string;
protocols?: string[];
items?: ProfileItemInput[];
}
export interface ProfileItem {
type: ProfileItemType;
value: string;
recursive: boolean;
}
export interface Profile {
id: string;
name: string;
enabled: boolean;
targetId: string;
protocols: Protocol[];
items: ProfileItem[];
}
export interface TargetInput {
id?: string;
name: string;
kind?: TargetKind | string;
protocol?: ProxyProtocol | string;
host: string;
port: number;
requiresComponent?: ComponentId | string;
}
export interface Target {
id: string;
name: string;
kind: TargetKind;
protocol: ProxyProtocol;
host: string;
port: number;
requiresComponent?: ComponentId;
}
export interface ComponentStatus {
id: ComponentId;
name: string;
state: ComponentState;
installed: boolean;
running: boolean;
version?: string;
path?: string;
problems: string[];
actions: string[];
}
export interface ActivityEntry {
id: string;
at: string;
level: ActivityLevel;
title: string;
message: string;
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/app.css';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -1,776 +0,0 @@
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #e5e7eb;
background: #101216;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #101216;
}
button,
input {
font: inherit;
}
button {
border: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.simple-shell {
display: block;
min-height: 100vh;
background: #101216;
padding: 0;
}
.simple-panel {
display: grid;
align-content: start;
min-height: 100vh;
width: 100%;
border: 0;
border-radius: 0;
background: #101216;
box-shadow: none;
padding: 18px 18px 96px;
}
.simple-header,
.finder-card,
.section-head,
.add-toolbar,
.process-add-line,
.app-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.simple-header {
margin: -18px -18px 16px;
border-bottom: 1px solid #2a2f3a;
background: #181b22;
padding: 14px 18px;
}
.simple-header small,
.simple-field span,
.app-row-main > div > span,
.config-path,
.finder-card span {
color: #8d99ae;
}
.simple-header h1,
.section-head h2 {
margin: 0;
letter-spacing: 0;
}
.simple-header h1 {
margin-top: 4px;
font-size: 22px;
line-height: 1.1;
font-weight: 650;
}
.section-head h2 {
font-size: 16px;
}
.ghost-button,
.add-toolbar button,
.process-add-line button,
.app-row button,
.open-config-button,
.service-button {
min-height: 36px;
border: 1px solid #343b49;
border-radius: 4px;
background: #242a35;
color: #eef2ff;
padding: 8px 12px;
cursor: pointer;
}
.ghost-button:hover,
.add-toolbar button:hover,
.process-add-line button:hover,
.app-row button:hover,
.open-config-button:hover,
.service-button:hover {
background: #2d3543;
}
.finder-card {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
justify-content: stretch;
min-height: 56px;
overflow: hidden;
border: 1px solid #2b3342;
border-radius: 4px;
background: #151923;
padding: 12px;
}
.finder-border-glow {
position: absolute;
z-index: 0;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.finder-card > :not(.finder-border-glow) {
position: relative;
z-index: 1;
}
.finder-card.checking .finder-border-glow,
.finder-card.working .finder-border-glow {
opacity: 1;
}
.finder-card.checking .finder-border-glow {
opacity: 0.78;
}
.finder-card.settling .finder-border-glow {
opacity: 0;
transition-duration: 0.7s;
}
.finder-border-glow-segment {
position: absolute;
display: block;
background: #93c5fd;
box-shadow:
0 0 8px rgba(96, 165, 250, 0.95),
0 0 16px rgba(34, 197, 94, 0.36);
opacity: 0;
}
.finder-border-glow-segment.top,
.finder-border-glow-segment.bottom {
width: 108px;
height: 2px;
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.right,
.finder-border-glow-segment.left {
width: 2px;
height: 64px;
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.top {
top: 0;
animation: finder-border-top 1.6s linear infinite;
}
.finder-border-glow-segment.right {
right: 0;
animation: finder-border-right 1.6s linear infinite;
}
.finder-border-glow-segment.bottom {
bottom: 0;
animation: finder-border-bottom 1.6s linear infinite;
}
.finder-border-glow-segment.left {
left: 0;
animation: finder-border-left 1.6s linear infinite;
}
.finder-text,
.app-row > div,
.app-row-main > div {
min-width: 0;
}
.finder-card strong,
.finder-card span,
.app-row-main strong,
.app-row-main > div > span {
display: block;
overflow-wrap: anywhere;
}
.status-light {
flex: 0 0 auto;
width: 11px;
height: 11px;
border-radius: 999px;
background: #ef4444;
}
.finder-card.found .status-light {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
}
.finder-card.missing .status-light {
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.finder-card.checking .status-light {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
}
.service-actions {
display: flex;
gap: 6px;
align-items: center;
}
.service-button {
min-width: 102px;
white-space: nowrap;
}
.service-button.stop {
color: #fecaca;
}
.simple-field {
display: grid;
gap: 7px;
margin: 14px 0;
}
.simple-field input,
.process-add-line input {
min-height: 42px;
width: 100%;
border: 1px solid #343b49;
border-radius: 4px;
background: #0d1016;
color: #f8fafc;
outline: none;
padding: 9px 11px;
}
.simple-field input:focus,
.process-add-line input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.apps-section {
display: grid;
gap: 10px;
margin-top: 8px;
}
.section-head span {
min-width: 28px;
border: 1px solid #343b49;
border-radius: 4px;
background: #1b202b;
color: #dbeafe;
padding: 3px 9px;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.add-toolbar {
justify-content: center;
padding: 4px 0;
}
.add-tile {
display: grid;
place-items: center;
width: 74px;
height: 48px;
border-color: #2b3342;
background: #131720;
color: #dbeafe;
padding: 0;
}
.add-tile svg {
display: block;
}
.add-tile:hover {
border-color: #3b82f6;
background: #182033;
}
.add-tile[aria-pressed="true"] {
border-color: #3b82f6;
background: #1e3a8a;
}
.tile-loading {
color: #dbeafe;
font-weight: 800;
animation: pulse 1s ease-in-out infinite;
}
.process-add-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px 88px;
}
.process-add-line button {
border-color: #166534;
background: #14532d;
font-weight: 700;
}
.process-add-line .process-cancel-button {
border-color: #343b49;
background: #242a35;
color: #cbd5e1;
font-weight: 600;
}
.process-add-line button:hover {
background: #166534;
}
.process-add-line .process-cancel-button:hover {
background: #2d3543;
}
.app-list {
display: grid;
gap: 8px;
}
.app-row-main {
display: flex;
align-items: center;
gap: 10px;
}
.app-row .item-icon {
display: grid;
flex: 0 0 auto;
align-self: center;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #0d1016;
color: #dbeafe;
line-height: 0;
}
.app-row .item-icon svg {
display: block;
}
.app-row,
.empty-state {
border: 1px solid #2b3342;
border-radius: 4px;
background: #131720;
padding: 10px 12px;
}
.app-row button {
color: #fecaca;
}
.empty-state {
color: #8d99ae;
min-height: 48px;
}
.list-skeleton {
display: grid;
gap: 8px;
}
.list-skeleton span {
min-height: 56px;
border: 1px solid #2b3342;
border-radius: 4px;
background:
linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent),
#131720;
background-size: 220% 100%;
animation: shimmer 1.15s linear infinite;
}
.command-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
margin-top: 14px;
}
.apply-button {
min-height: 46px;
width: 100%;
border: 1px solid #16a34a;
border-radius: 4px;
background: #22c55e;
color: #04130a;
font-weight: 800;
cursor: pointer;
}
.apply-button:hover {
background: #4ade80;
}
.open-config-button {
min-height: 46px;
width: 100%;
}
.config-path {
margin: 10px 0 0;
font-size: 12px;
overflow-wrap: anywhere;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.log-dock {
position: fixed;
right: 10px;
bottom: 10px;
left: 10px;
z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 48px;
border: 1px solid #2b3342;
border-radius: 4px;
background: rgba(19, 23, 32, 0.98);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34);
padding: 7px;
}
.log-dock.error {
border-color: rgba(239, 68, 68, 0.58);
}
.log-dock.success {
border-color: rgba(34, 197, 94, 0.58);
}
.log-dock.info {
border-color: rgba(59, 130, 246, 0.58);
}
.log-current {
display: flex;
min-width: 0;
gap: 10px;
align-items: baseline;
padding: 0 8px;
transition: opacity 180ms ease, transform 180ms ease;
}
.log-current.hidden {
color: #8d99ae;
opacity: 0.72;
}
.log-current.visible {
opacity: 1;
}
.log-current strong,
.log-current span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-current strong {
flex: 0 0 auto;
}
.log-current span {
min-width: 0;
}
.log-muted {
color: #8d99ae;
}
.log-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 34px;
border: 1px solid #343b49;
border-radius: 4px;
background: #242a35;
color: #eef2ff;
padding: 7px 10px;
cursor: pointer;
}
.log-toggle:hover {
background: #2d3543;
}
.log-toggle span {
min-width: 22px;
border-radius: 4px;
background: #1b202b;
color: #dbeafe;
padding: 2px 6px;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.log-history {
display: grid;
grid-column: 1 / -1;
gap: 6px;
max-height: 230px;
overflow: auto;
border-top: 1px solid #2b3342;
padding-top: 7px;
}
.log-history-row {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid #2b3342;
border-radius: 4px;
background: #0d1016;
padding: 8px;
}
.log-history-row.error {
border-color: rgba(239, 68, 68, 0.42);
}
.log-history-row.success {
border-color: rgba(34, 197, 94, 0.36);
}
.log-history-row.info {
border-color: rgba(59, 130, 246, 0.36);
}
.log-history-row time {
color: #8d99ae;
font-size: 12px;
}
.log-history-row strong,
.log-history-row span {
display: block;
overflow-wrap: anywhere;
}
.log-history-row span {
color: #b6c2d4;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
@keyframes finder-border-top {
0% {
left: -116px;
opacity: 0;
}
4%,
23% {
opacity: 1;
}
25%,
100% {
left: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-right {
0%,
25% {
top: -72px;
opacity: 0;
}
29%,
48% {
opacity: 1;
}
50%,
100% {
top: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-bottom {
0%,
50% {
right: -116px;
opacity: 0;
}
54%,
73% {
opacity: 1;
}
75%,
100% {
right: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-left {
0%,
75% {
bottom: -72px;
opacity: 0;
}
79%,
98% {
opacity: 1;
}
100% {
bottom: calc(100% + 8px);
opacity: 0;
}
}
@keyframes shimmer {
from {
background-position: 220% 0;
}
to {
background-position: -220% 0;
}
}
@media (max-width: 680px) {
.simple-shell {
padding: 0;
}
.simple-panel {
padding: 14px 14px 100px;
}
.simple-header,
.app-row {
align-items: stretch;
flex-direction: column;
}
.add-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.add-tile {
width: 100%;
}
.process-add-line {
grid-template-columns: 1fr;
}
.finder-card {
grid-template-columns: auto minmax(0, 1fr);
}
.service-actions {
grid-column: 1 / -1;
}
.service-button {
flex: 1;
}
.command-row {
grid-template-columns: 1fr;
}
.log-dock {
right: 8px;
left: 8px;
grid-template-columns: 1fr;
}
.log-current {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.log-history-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

View File

@@ -1,18 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
host: host || false,
port: 5173,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**'],
},
},
});

View File

@@ -9,9 +9,9 @@ services:
environment: environment:
APP_MODE: client APP_MODE: client
PORT: ${PORT:-3456} PORT: ${PORT:-3456}
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090}
PROXY_BIND_IP: 0.0.0.0 PROXY_BIND_IP: 0.0.0.0
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json SING_BOX_CONFIG: /etc/sing-box/config.json
@@ -29,13 +29,13 @@ services:
no_proxy: "localhost,127.0.0.1,host.docker.internal" no_proxy: "localhost,127.0.0.1,host.docker.internal"
ports: ports:
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}" - "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}" - "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}"
volumes: volumes:
- vpn-proxy-client-data:/var/lib/vpn-proxy - vpn-proxy-client-data:/var/lib/vpn-proxy
- sing-box-client-cache:/var/lib/sing-box - sing-box-client-cache:/var/lib/sing-box
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

File diff suppressed because it is too large Load Diff

View File

@@ -1,774 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VPN Proxy Route Console Redesign</title>
<style>
:root {
color-scheme: light;
--bg: oklch(0.965 0.008 232);
--surface: oklch(0.986 0.006 232);
--surface-2: oklch(0.948 0.009 232);
--surface-3: oklch(0.918 0.014 232);
--ink: oklch(0.238 0.028 238);
--muted: oklch(0.47 0.028 238);
--subtle: oklch(0.62 0.022 238);
--line: oklch(0.835 0.018 232);
--line-strong: oklch(0.72 0.032 232);
--blue: oklch(0.56 0.14 244);
--blue-soft: oklch(0.915 0.045 244);
--green: oklch(0.61 0.13 153);
--green-soft: oklch(0.915 0.052 153);
--amber: oklch(0.72 0.13 74);
--amber-soft: oklch(0.93 0.07 74);
--red: oklch(0.58 0.15 27);
--radius: 8px;
--shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-size: 14px;
line-height: 1.45;
}
button,
input,
select {
font: inherit;
}
.page {
min-height: 100vh;
padding: 24px;
}
.shell {
max-width: 1320px;
min-height: calc(100vh - 48px);
margin: 0 auto;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 64px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: oklch(0.978 0.007 232);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
width: 32px;
height: 32px;
border-radius: 7px;
background:
linear-gradient(135deg, oklch(0.52 0.13 244), oklch(0.62 0.12 153));
position: relative;
}
.mark::after {
content: "";
position: absolute;
inset: 8px;
border: 2px solid oklch(0.985 0.005 232);
border-left-color: transparent;
border-radius: 50%;
}
.brand h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.brand span {
color: var(--muted);
font-size: 12px;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 11px;
border: 1px solid oklch(0.73 0.05 153);
border-radius: 999px;
background: var(--green-soft);
color: oklch(0.34 0.08 153);
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.btn {
min-height: 34px;
padding: 0 13px;
border-radius: 7px;
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--ink);
font-weight: 650;
cursor: default;
}
.btn.primary {
border-color: oklch(0.49 0.13 244);
background: var(--blue);
color: oklch(0.985 0.005 232);
}
.workspace {
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
gap: 0;
min-height: 0;
}
.rail {
padding: 18px 14px;
border-right: 1px solid var(--line);
background: oklch(0.956 0.009 232);
}
.nav-title,
.panel-label,
.field-label {
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.mode-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.mode {
width: 100%;
padding: 12px;
display: grid;
grid-template-columns: auto 1fr;
gap: 11px;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.mode.active {
border-color: oklch(0.68 0.08 244);
background: var(--blue-soft);
}
.mode-dot {
width: 11px;
height: 11px;
margin-top: 4px;
border-radius: 50%;
background: var(--subtle);
}
.mode.active .mode-dot {
background: var(--blue);
}
.mode strong {
display: block;
font-size: 14px;
}
.mode span {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
.rail-section {
margin-top: 24px;
}
.mini-list {
display: grid;
gap: 7px;
margin-top: 10px;
}
.mini-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-size: 12px;
}
.mini-row span {
color: var(--muted);
}
.main {
min-width: 0;
padding: 22px;
overflow: auto;
}
.route-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: start;
margin-bottom: 18px;
}
.route-head h2 {
margin: 0;
font-size: 28px;
line-height: 1.12;
}
.route-head p {
max-width: 68ch;
margin: 8px 0 0;
color: var(--muted);
}
.health {
min-width: 210px;
padding: 12px 14px;
border: 1px solid oklch(0.76 0.07 153);
border-radius: var(--radius);
background: var(--green-soft);
}
.health strong {
display: block;
font-size: 18px;
}
.health span {
color: oklch(0.38 0.07 153);
font-size: 12px;
}
.route-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.node {
min-height: 126px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
position: relative;
}
.node.active {
border-color: oklch(0.72 0.075 153);
background: var(--green-soft);
}
.node.pending {
border-color: oklch(0.8 0.09 74);
background: var(--amber-soft);
}
.node small {
color: var(--muted);
font-weight: 700;
}
.node strong {
display: block;
margin-top: 9px;
font-size: 18px;
}
.node span {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.flow-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 13px;
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
overflow-x: auto;
white-space: nowrap;
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.flow-line b {
color: var(--blue);
}
.flow-line span {
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
gap: 14px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 50px;
padding: 0 14px;
border-bottom: 1px solid var(--line);
}
.panel-head h3 {
margin: 0;
font-size: 15px;
}
.panel-body {
padding: 14px;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.control-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.input,
.select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line-strong);
border-radius: 7px;
background: oklch(0.992 0.004 232);
color: var(--ink);
padding: 0 11px;
}
.summary-list {
display: grid;
gap: 8px;
}
.summary-row {
display: grid;
grid-template-columns: 94px minmax(0, 1fr);
gap: 10px;
align-items: baseline;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.summary-row:last-child {
border-bottom: 0;
}
.summary-row small {
color: var(--muted);
font-weight: 700;
}
.summary-row strong {
overflow-wrap: anywhere;
}
.side {
padding: 18px 14px;
border-left: 1px solid var(--line);
background: oklch(0.956 0.009 232);
overflow: auto;
}
.copy-stack {
display: grid;
gap: 8px;
margin-top: 10px;
}
.copy-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.copy-row button {
border: 0;
background: transparent;
color: var(--blue);
font-weight: 700;
}
.side-panel {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.activity {
display: grid;
gap: 0;
}
.activity-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.activity-row:last-child {
border-bottom: 0;
}
.activity-row time {
color: var(--muted);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
}
.activity-row strong {
display: block;
font-size: 13px;
}
.activity-row span {
display: block;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1080px) {
.workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
.side {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid var(--line);
}
.route-strip,
.settings-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.page {
padding: 10px;
}
.shell {
min-height: calc(100vh - 20px);
}
.topbar,
.route-head,
.workspace {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
padding: 14px;
}
.top-actions {
flex-wrap: wrap;
}
.rail {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.route-strip,
.settings-grid,
.control-row {
grid-template-columns: 1fr;
}
.main,
.side {
padding: 14px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="shell" aria-label="VPN Proxy redesign preview">
<header class="topbar">
<div class="brand">
<div class="mark" aria-hidden="true"></div>
<div>
<h1>VPN Proxy Client</h1>
<span>Local control panel, macOS Docker mode</span>
</div>
</div>
<div class="top-actions">
<span class="status-pill">sing-box running</span>
<button class="btn">Restart</button>
<button class="btn primary">Apply route</button>
</div>
</header>
<div class="workspace">
<aside class="rail" aria-label="Connection modes">
<p class="nav-title">Режим подключения</p>
<div class="mode-list">
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Общий gateway</strong>
<span>192.168.50.111:8080</span>
</span>
</button>
<button class="mode active" type="button">
<span class="mode-dot"></span>
<span>
<strong>Локальный VPN</strong>
<span>Finland 02 selected</span>
</span>
</button>
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Напрямую</strong>
<span>без VPN и gateway</span>
</span>
</button>
</div>
<section class="rail-section">
<p class="nav-title">Проверки</p>
<div class="mini-list">
<div class="mini-row"><strong>Docker</strong><span>ready</span></div>
<div class="mini-row"><strong>Ports</strong><span>8082 open</span></div>
<div class="mini-row"><strong>Config</strong><span>valid</span></div>
</div>
</section>
</aside>
<section class="main">
<div class="route-head">
<div>
<h2>Текущий маршрут: приложения Mac идут через локальный VPN</h2>
<p>
Главный экран показывает не настройки как список, а фактический путь трафика:
от приложения до интернета, с портом, выбранным режимом и состоянием сервиса.
</p>
</div>
<div class="health">
<strong>42 ms</strong>
<span>последняя проверка маршрута</span>
</div>
</div>
<div class="route-strip" aria-label="Active route">
<div class="node">
<small>Источник</small>
<strong>Mac apps</strong>
<span>браузер, Discord, Telegram</span>
</div>
<div class="node active">
<small>Локальный proxy</small>
<strong>127.0.0.1:8082</strong>
<span>HTTP и SOCKS5</span>
</div>
<div class="node active">
<small>Режим</small>
<strong>Local VPN</strong>
<span>Finland 02</span>
</div>
<div class="node pending">
<small>Выход</small>
<strong>Internet</strong>
<span>проверка 11:04</span>
</div>
</div>
<div class="flow-line" aria-label="Route path">
<b>Mac apps</b><span>></span><b>127.0.0.1:8082</b><span>></span><b>sing-box</b><span>></span><b>Finland 02</b><span>></span><b>Internet</b>
</div>
<div class="settings-grid">
<section class="panel">
<div class="panel-head">
<h3>Настройка выбранного режима</h3>
<button class="btn">Load subscription</button>
</div>
<div class="panel-body">
<div class="form-grid">
<label class="field">
<span class="field-label">Подписка или VLESS</span>
<div class="control-row">
<input class="input" value="https://provider.example/subscription" />
<button class="btn">Refresh</button>
</div>
</label>
<label class="field">
<span class="field-label">VPN-сервер</span>
<div class="control-row">
<select class="select">
<option>Finland 02, 42 ms, 18 GB left</option>
</select>
<button class="btn primary">Connect</button>
</div>
</label>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Сводка</h3>
</div>
<div class="panel-body">
<div class="summary-list">
<div class="summary-row"><small>Service</small><strong>running since 10:52</strong></div>
<div class="summary-row"><small>Config</small><strong>applied 2 minutes ago</strong></div>
<div class="summary-row"><small>Fallback</small><strong>VPN by default</strong></div>
<div class="summary-row"><small>Quota</small><strong>18 GB left</strong></div>
</div>
</div>
</section>
</div>
</section>
<aside class="side" aria-label="Local proxy details">
<p class="panel-label">Адреса для приложений</p>
<div class="copy-stack">
<div class="copy-row"><span>http://127.0.0.1:8082</span><button>Copy</button></div>
<div class="copy-row"><span>socks5://127.0.0.1:8082</span><button>Copy</button></div>
</div>
<section class="side-panel">
<div class="panel-head">
<h3>Порт proxy</h3>
</div>
<div class="panel-body">
<div class="control-row">
<input class="input" value="8082" />
<button class="btn">Save</button>
</div>
</div>
</section>
<section class="side-panel">
<div class="panel-head">
<h3>Активность</h3>
</div>
<div class="panel-body">
<div class="activity">
<div class="activity-row">
<time>11:04</time>
<span><strong>Route check passed</strong><span>Finland 02 returned 42 ms</span></span>
</div>
<div class="activity-row">
<time>11:02</time>
<span><strong>Port changed</strong><span>8080 was busy, 8082 selected</span></span>
</div>
<div class="activity-row">
<time>10:59</time>
<span><strong>Subscription refreshed</strong><span>12 servers available</span></span>
</div>
</div>
</div>
</section>
</aside>
</div>
</section>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
# Goal: Windows Tauri Proxy Client
Use Krypton Execution to execute `docs/goals/windows-modular-client/PLAN.md`.
Core rules:
- Treat `PLAN.md` as the source plan.
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
- Build a separate Tauri 2 Windows desktop app under `apps/windows-client`.
- Do not implement Windows by extending the current Node gateway/client server.
- Keep Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
- Make external proxy target + ProxiFyre profile apply the MVP.
- Keep Local sing-box optional; it must not be required for external target profiles.
- Keep generated ProxiFyre and sing-box configs derived from source models.
- Capture acceptance evidence from the target user's perspective and record it in `EVIDENCE.md`.
- Say "implemented but unproven" if Windows-only privileged evidence cannot be captured.

View File

@@ -1,506 +0,0 @@
# Windows Tauri Proxy Client Implementation Plan
**Intent:** Build a separate Windows desktop proxy management app using Tauri 2, React, TypeScript, and Rust. The app manages three independent components: Control App, Proxyfier Layer, and optional Local sing-box.
**Current Behavior:** The repo contains a gateway/client Node + React web application and planning documents for a Windows mode inside that app. A newer product/technology brief now targets a standalone Windows desktop utility instead of extending the existing web control panel.
**Expected Outcome:** A compact Windows desktop utility lets the user configure app-level proxy routing through external SOCKS5/HTTP targets first, then optionally install and use local sing-box. The app remains useful when sing-box is absent.
**Target-Perspective Output:** A Windows user opens the desktop app, sees Overview, Profiles, Targets, Components, and Logs, adds Discord or another process/folder/exe profile, selects an external proxy target, applies changes to the proxyfier layer, and sees component status plus recent activity. Later, installing Local sing-box adds a local target without changing the profile model.
**Truth Owner:** Source configuration lives in the Tauri app's Rust domain model and JSON files under `C:\ProgramData\VpnProxy`. Generated ProxiFyre and sing-box configs are derived artifacts. Privileged install/service operations are owned by explicit helper/installer flows, not by React UI state.
**Contract Boundary:** React UI calls typed Tauri commands. Tauri Rust backend validates and persists profiles/targets/components. Proxy routing is behind a `ProxyRouterAdapter` boundary, with ProxiFyre as the first adapter. Privileged operations go through explicit helper/install commands returning structured JSON.
**Cutover:** Supersede the prior Node `APP_MODE=windows` implementation direction. Keep existing gateway/client code intact. New Windows work lives under a separate Tauri app slice.
**Displaced Path:** The old plan to add Windows mode into `src/server`/`src/web` is demoted to historical context. Do not add a third app mode to the current Node server for this product.
**Value Density:** The smallest high-value slice is the desktop app MVP with external SOCKS5 target + ProxiFyre profile apply. Local sing-box is optional and comes after the proxyfier MVP is proven.
**Evidence Gate:** Evidence must include target-perspective app proof: built Tauri app or dev window screenshot/state, generated proxyfier config artifact, mocked or real helper response, and manual Windows checklist when privileged components are involved.
**Acceptance Evidence:** Automated tests pass for Rust/TypeScript domain logic, app build succeeds, the MVP can create a profile and generate/apply ProxiFyre config against an external target, and Windows manual evidence proves independent component behavior.
**Evidence Lane:** Record command output, app screenshots/state payloads, generated configs, and manual verification in `docs/goals/windows-modular-client/EVIDENCE.md`.
**Kill Criteria:** No Windows implementation inside current Node gateway/client server; no mandatory sing-box dependency; no generated config as source truth; no hidden installation during profile apply; no direct UI parsing of raw PowerShell/stdout.
**Architecture Slice:** New standalone Tauri app under `apps/windows-client`, plus docs updates that point from older Windows plans to this plan.
**Plan Review Gate:** Requires PRE review before implementation execution.
## Source Brief
Product and technology source brief:
- `docs/windows-client-product-tech-brief.md`
This plan turns that brief into an execution-ready implementation sequence.
## Outcome Contract
Plan title: Windows Tauri Proxy Client
Intent: Build a native-feeling Windows utility that manages app-level proxy routing while keeping Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
Current behavior:
- Existing runtime code is a Node HTTP server and Vite/React web UI for gateway and Mac-style client modes.
- Earlier Windows docs describe adding Windows mode to that existing app.
- The selected direction is now Tauri 2 + React/TypeScript + Rust as a separate Windows desktop app.
Expected outcome:
- `apps/windows-client` contains a Tauri 2 app.
- The app has Overview, Profiles, Targets, Components, and Logs surfaces.
- Profiles store process/folder/exe source items.
- Targets store external proxy endpoints and optional local sing-box.
- ProxiFyre is the first proxy router adapter.
- Local sing-box is optional and never required for external target profiles.
Target-perspective output:
- User can install/run only the Control App.
- User can see Proxyfier and Local sing-box as separate components.
- User can add an external SOCKS5 target.
- User can add a Discord process profile.
- User can apply the profile to generated ProxiFyre config.
- User sees activity confirming whether apply succeeded or why it was blocked.
Truth owner:
- Rust core domain crate owns normalized models and validation.
- JSON source files under `C:\ProgramData\VpnProxy\config` own persisted profiles/targets/component preferences.
- `ProxyRouterAdapter` owns conversion from source models to proxy-router generated config.
- `SingBoxAdapter` owns generated local sing-box config and service contract.
- React UI owns only transient UI state.
Contract boundary:
- UI -> Tauri commands with typed request/response DTOs.
- Tauri commands -> Rust core services.
- Core services -> adapter traits.
- Adapter traits -> helper/install/service commands when privileged operations are needed.
- Helper/install commands return structured JSON, never unstructured text for app logic.
Cutover:
- Add superseded notes to old Windows Node-mode docs.
- Keep `docs/windows-client-product-tech-brief.md` as product brief.
- Make this `PLAN.md` the execution plan.
- Do not implement Windows by adding `APP_MODE=windows` to the current Node server.
Displaced path:
- Displace old "Windows mode in current web app" implementation.
- Displace "full install vs ProxiFyre-only" as dominant architecture; those become recipes composed from separate components.
Value density:
- MVP must prove app-level routing with external proxy target and ProxiFyre before local sing-box work expands scope.
Evidence gate:
- Tests and build are not enough.
- Capture app-visible state and generated config.
- Capture Windows manual evidence for service/helper actions when those tasks execute.
Acceptance evidence:
- `cargo test` or equivalent Rust tests for domain/adapters.
- frontend typecheck/test/build for React.
- Tauri dev/build command result.
- Screenshot or state dump showing Windows app surfaces.
- Generated ProxiFyre config from a sample profile.
- Manual Windows checklist when privileged components are present.
Non-goals:
- No Electron.
- No extension of the current Node gateway/client UI for Windows MVP.
- No global Windows system proxy changes.
- No transparent routing without a proxy router.
- No mandatory local sing-box.
- No direct coupling of UI to ProxiFyre-specific config shape.
Risk if wrong:
- If built inside the current Node app, the product will inherit gateway/client assumptions and conflict with the selected Tauri direction.
- If ProxiFyre is not behind an adapter, licensing or engine changes will force UI/data rewrites.
- If privileged work is hidden behind apply, users lose control and failures become hard to diagnose.
## Architecture Slice
Files/directories to create:
- `apps/windows-client/package.json`
- `apps/windows-client/vite.config.ts`
- `apps/windows-client/tsconfig.json`
- `apps/windows-client/src/main.tsx`
- `apps/windows-client/src/app/App.tsx`
- `apps/windows-client/src/app/routes.tsx`
- `apps/windows-client/src/api/tauriCommands.ts`
- `apps/windows-client/src/domain/types.ts`
- `apps/windows-client/src/features/overview/*`
- `apps/windows-client/src/features/profiles/*`
- `apps/windows-client/src/features/targets/*`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/logs/*`
- `apps/windows-client/src/styles/*`
- `apps/windows-client/src-tauri/Cargo.toml`
- `apps/windows-client/src-tauri/tauri.conf.json`
- `apps/windows-client/src-tauri/capabilities/default.json`
- `apps/windows-client/src-tauri/src/main.rs`
- `apps/windows-client/src-tauri/src/commands.rs`
- `apps/windows-client/src-tauri/src/models.rs`
- `apps/windows-client/src-tauri/src/storage.rs`
- `apps/windows-client/src-tauri/src/activity.rs`
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
- `apps/windows-client/src-tauri/src/helper.rs`
- `apps/windows-client/src-tauri/tests/*`
- `apps/windows-client/scripts/install-control-app.ps1`
- `apps/windows-client/scripts/install-proxyfier.ps1`
- `apps/windows-client/scripts/install-singbox.ps1`
Files to modify:
- `README.md`
- `docs/roadmap.md`
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
- `docs/superpowers/plans/2026-05-21-windows-client.md`
- `docs/goals/windows-modular-client/GOAL.md`
- `docs/goals/windows-modular-client/EVIDENCE.md`
Files to avoid:
- `src/server/*` except if a later explicit migration asks for shared code extraction.
- `src/web/*` for Windows MVP.
- Docker, entrypoint, and compose files.
- macOS installer.
Source of truth:
- `C:\ProgramData\VpnProxy\config\profiles.json`
- `C:\ProgramData\VpnProxy\config\targets.json`
- `C:\ProgramData\VpnProxy\config\components.json`
- `C:\ProgramData\VpnProxy\state\activity.json`
Derived artifacts:
- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json`
- `C:\ProgramData\VpnProxy\generated\sing-box-config.json`
- ProxiFyre runtime config copied/backed up by helper/apply operation.
Read path:
- React UI calls Tauri commands.
- Tauri commands read JSON source via Rust storage service.
- Component status combines source preferences, filesystem checks, service checks, and helper responses.
Write path:
- React UI sends typed mutations.
- Rust validates with domain models.
- Rust writes source JSON atomically with backups.
- Apply generates derived config and invokes adapter/helper.
Integration points:
- ProxiFyre adapter emits `app-config.json` compatible output.
- Local sing-box adapter emits `sing-box` JSON config and validates via `sing-box check` when binary exists.
- Tauri sidecar/helper permissions are declared explicitly.
- Installer scripts may be launched or displayed explicitly, never silently during apply.
Migration/cutover:
- Older Windows docs point to this plan and source brief.
- Existing Node app remains gateway/client only.
- If shared subscription parsing is needed later, extract it intentionally into a shared package rather than importing server internals.
Acceptance evidence gate:
- MVP evidence must show external-target flow works without local sing-box.
- Optional sing-box evidence must show the same profile model can switch targets after installing sing-box.
## Task Board
### Task 1: Supersede Old Windows Node Plan
Owner: main agent
Input:
- `docs/windows-client-product-tech-brief.md`
- old Windows docs/plans
Files allowed:
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
- `docs/superpowers/plans/2026-05-21-windows-client.md`
- `docs/roadmap.md`
- `README.md`
Files forbidden:
- Runtime source files.
Output:
- Old Windows documents clearly point to this Tauri plan and no longer read as implementation authority.
Evidence:
- `rg -n "Tauri|superseded|windows-client-product-tech-brief|apps/windows-client" README.md docs`
Depends on: none
Parallel safe: yes
### Task 2: Scaffold Tauri App Shell
Owner: main agent
Input:
- Tauri 2 app structure
- Product brief UI surfaces
Files allowed:
- `apps/windows-client/package.json`
- `apps/windows-client/vite.config.ts`
- `apps/windows-client/tsconfig.json`
- `apps/windows-client/index.html`
- `apps/windows-client/src/*`
- `apps/windows-client/src-tauri/*`
Files forbidden:
- Current root `src/server/*`
- Current root `src/web/*`
Output:
- Tauri app starts with empty shell and five navigation surfaces.
- No business logic yet.
Evidence:
- `cd apps/windows-client && npm install && npm run build`
- `cd apps/windows-client/src-tauri && cargo test` if Rust tests exist
Depends on: Task 1
Parallel safe: no
### Task 3: Define Domain Models And Validation
Owner: main agent
Input:
- Profile/Target/Component models from brief
Files allowed:
- `apps/windows-client/src-tauri/src/models.rs`
- `apps/windows-client/src-tauri/src/validation.rs`
- `apps/windows-client/src/domain/types.ts`
- `apps/windows-client/src-tauri/tests/domain_tests.rs`
Files forbidden:
- Adapter/helper code except trait references.
Output:
- Typed Rust models for `Profile`, `ProfileItem`, `Target`, `ComponentStatus`, `ActivityEntry`.
- TypeScript DTOs mirror Rust command responses.
- Validation rejects malformed ports/protocols but allows missing local sing-box.
Evidence:
- Rust tests showing process/folder/exe normalization and external target validation.
Depends on: Task 2
Parallel safe: no
### Task 4: Implement JSON Storage And Activity Log
Owner: main agent
Input:
- Domain models from Task 3
Files allowed:
- `apps/windows-client/src-tauri/src/storage.rs`
- `apps/windows-client/src-tauri/src/activity.rs`
- `apps/windows-client/src-tauri/tests/storage_tests.rs`
Files forbidden:
- UI screens except command wiring stubs.
Output:
- Atomic JSON read/write for profiles, targets, components, and activity.
- Backups before overwriting source files.
- Config root defaults to `C:\ProgramData\VpnProxy`, with test override.
Evidence:
- Tests prove roundtrip, invalid JSON fallback behavior, backup creation, activity cap/sort.
Depends on: Task 3
Parallel safe: no
### Task 5: Add Proxy Router Adapter Boundary And ProxiFyre Adapter
Owner: main agent
Input:
- Domain models and storage
Files allowed:
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
- `apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs`
Files forbidden:
- Direct UI coupling to ProxiFyre config fields.
Output:
- `ProxyRouterAdapter` trait.
- `ProxiFyreAdapter` generates config from enabled profiles and targets.
- External target flow does not require sing-box.
Evidence:
- Test generates ProxiFyre config for Discord + external SOCKS5 target.
- Test blocks local-singbox target only when target requires missing component.
Depends on: Task 4
Parallel safe: no
### Task 6: Add Tauri Commands
Owner: main agent
Input:
- Storage and adapter services
Files allowed:
- `apps/windows-client/src-tauri/src/commands.rs`
- `apps/windows-client/src-tauri/src/main.rs`
- `apps/windows-client/src/api/tauriCommands.ts`
- `apps/windows-client/src-tauri/tests/command_tests.rs`
Files forbidden:
- Full UI implementation beyond command call wrappers.
Output:
- Commands for status, profiles, targets, components, scan/resolve preview, apply, logs.
- Commands return structured responses only.
Evidence:
- Command tests or integration tests prove apply generates derived config and records activity using a mock adapter/helper.
Depends on: Task 5
Parallel safe: no
### Task 7: Build MVP UI
Owner: main agent
Input:
- Tauri command API
- Product brief layout
Files allowed:
- `apps/windows-client/src/app/*`
- `apps/windows-client/src/features/overview/*`
- `apps/windows-client/src/features/profiles/*`
- `apps/windows-client/src/features/targets/*`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/logs/*`
- `apps/windows-client/src/styles/*`
Files forbidden:
- Rust adapter behavior except fixing DTO mismatches.
Output:
- Compact utility UI with Overview, Profiles, Targets, Components, Logs.
- User can create/edit profile, external target, and trigger apply.
- Missing sing-box is shown as valid optional state.
Evidence:
- `npm run build`
- Screenshot or browser/app state showing missing sing-box and usable external target flow.
Depends on: Task 6
Parallel safe: no
### Task 8: Implement Helper And Explicit Installer Boundary
Owner: main agent
Input:
- Component model
- Security model from brief
Files allowed:
- `apps/windows-client/src-tauri/src/helper.rs`
- `apps/windows-client/src-tauri/capabilities/default.json`
- `apps/windows-client/scripts/install-control-app.ps1`
- `apps/windows-client/scripts/install-proxyfier.ps1`
- `apps/windows-client/scripts/install-singbox.ps1`
- `apps/windows-client/src-tauri/tests/helper_tests.rs`
Files forbidden:
- Hidden installer invocation inside profile apply.
Output:
- Helper command abstraction for status/service/apply.
- Installer scripts are explicit and idempotent.
- Tauri sidecar/shell permissions are narrow and documented.
Evidence:
- Helper tests with mock command runner.
- PowerShell parser checks for installer scripts.
- Capability file shows limited sidecar permissions.
Depends on: Task 6
Parallel safe: partly, after command DTOs are stable
### Task 9: Add Optional Local Sing-Box Adapter
Owner: main agent
Input:
- sing-box target model
- service/helper boundary
Files allowed:
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/targets/*`
Files forbidden:
- Making sing-box mandatory for external targets.
Output:
- Generate local sing-box config.
- Validate via `sing-box check` when binary exists.
- Local target appears only when installed/configured or as an explicit install prompt.
Evidence:
- Tests show external target apply works without sing-box.
- Tests show local-singbox target requires installed/running component.
Depends on: Tasks 5 and 8
Parallel safe: no
### Task 10: Package, Verify, And Record Evidence
Owner: main agent
Input:
- Completed MVP implementation
Files allowed:
- `apps/windows-client/*`
- `README.md`
- `docs/roadmap.md`
- `docs/goals/windows-modular-client/EVIDENCE.md`
Files forbidden:
- Unrelated app code.
Output:
- Build/test commands documented.
- README explains separate Control App, Proxyfier, and Local sing-box install flows.
- Evidence file captures automated and target-perspective proof.
Evidence:
- `npm run build`
- Rust tests
- Tauri build/dev proof
- generated ProxiFyre config summary
- UI screenshot/state
- Windows manual checklist, or clearly mark `implemented but unproven` for Windows-only service behavior if not run on a Windows host.
Depends on: all previous tasks
Parallel safe: no
## Manual Windows Verification Checklist
1. Install/run only Control App.
2. Verify Proxyfier and Local sing-box show missing as separate components.
3. Add external SOCKS5 target.
4. Add Discord process profile.
5. Apply profile; verify generated ProxiFyre config and activity entry.
6. Install Proxyfier separately; verify status changes.
7. Apply profile to real Proxyfier service.
8. Install Local sing-box separately.
9. Import subscription or config, select outbound, and start Local sing-box.
10. Switch existing profile from external target to Local sing-box and apply.
11. Stop/restart Proxyfier and Local sing-box separately.
12. Copy diagnostics and verify secrets are redacted.

View File

@@ -8,7 +8,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым | | `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации | | `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | активное направление: `docs/goals/windows-modular-client/PLAN.md` | | `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
## Gateway mode ## Gateway mode
@@ -84,32 +84,15 @@
## Windows gaming mode ## Windows gaming mode
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами. Цель: сохранить сценарий для Discord/Vesktop/игр.
Current checkpoint:
- MVP slice exists under `apps/windows-client`.
- Frontend build passes with `npm run build`.
- Rust/Tauri native verification requires installing Rust/rustup and Visual Studio Build Tools with MSVC/Windows SDK.
- Local sing-box is optional; external SOCKS5 targets remain the first verified path.
Требования: Требования:
- Standalone Tauri 2 + React/TypeScript + Rust app under `apps/windows-client`. - Native `sing-box.exe`.
- Profiles for process/folder/exe app routing. - Scheduled task или Windows service.
- External SOCKS5/HTTP targets first; local `sing-box` is optional. - ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
- Proxyfier adapter boundary with ProxiFyre as the first engine. - Управление из PowerShell helper.
- Explicit installers for Control App, Proxyfier Layer, and Local sing-box. - Позже можно сделать Electron/Tauri UI поверх privileged helper.
- Privileged helper/install operations return structured JSON.
Source docs:
- Product/tech brief: `docs/windows-client-product-tech-brief.md`.
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`.
Superseded:
- The old Node `APP_MODE=windows` plan in `docs/superpowers/plans/2026-05-21-windows-client.md` is historical context, not the active implementation path.
## Рабочий порядок ## Рабочий порядок
@@ -119,4 +102,4 @@ Superseded:
4. Реализовать Vite + React UI для subscription -> server select -> apply. 4. Реализовать Vite + React UI для subscription -> server select -> apply.
5. Добавить gateway docs/install script. 5. Добавить gateway docs/install script.
6. Потом переносить desktop-proxy. 6. Потом переносить desktop-proxy.
7. Потом реализовать standalone Windows Tauri client по `docs/goals/windows-modular-client/PLAN.md`. 7. Потом приводить Windows mode к новой архитектуре.

View File

@@ -1,12 +1,5 @@
# Windows Client Implementation Plan # Windows Client Implementation Plan
> Superseded: do not execute this Node `APP_MODE=windows` plan as the current
> Windows implementation path. The active plan is the standalone Tauri 2 desktop
> app in `docs/goals/windows-modular-client/PLAN.md`, based on
> `docs/windows-client-product-tech-brief.md`.
> Content below is retained for historical context and may contradict the active
> Tauri plan.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore the Windows proxy workflow as a script-first product with two install modes: full local `sing-box` + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI. **Goal:** Restore the Windows proxy workflow as a script-first product with two install modes: full local `sing-box` + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI.

View File

@@ -1,470 +0,0 @@
# VPN Proxy Client Route Console Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance.
**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched.
**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact.
**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html`
---
## Current Findings
- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`.
- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool.
- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now?
- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story.
- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route.
## Target Design
Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard.
The first viewport should show:
- top status: service running, restart, apply route;
- left mode rail: Gateway, Local VPN, Direct;
- main route strip: `Mac apps > local proxy > selected route > Internet`;
- right utility panel: copy proxy addresses, proxy port, recent activity;
- settings below route: only the form for the selected mode.
## File Structure
- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers.
- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query.
- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations.
- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions.
---
### Task 1: Lock Route Contract Before UI Changes
**Files:**
- Modify: `test/web/client-route.test.js`
- [ ] **Step 1: Add tests for all user-visible route statuses**
Add these cases to `test/web/client-route.test.js`:
```js
test('resolves running local VPN route', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
selectedTag: 'finland-02',
clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false },
},
activeServer: { tag: 'finland-02' },
});
assert.equal(route.mode, 'vpn');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8082');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']);
});
test('resolves gateway route when shared proxy is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
clientSettings: {
sharedProxyEnabled: true,
sharedProxy: { host: '192.168.50.111', port: 8080 },
},
},
});
assert.equal(route.mode, 'gateway');
assert.equal(route.status, 'connected');
assert.equal(route.target, '192.168.50.111:8080');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']);
});
test('resolves direct route when home bypass is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 },
},
});
assert.equal(route.mode, 'direct');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8084');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']);
});
```
- [ ] **Step 2: Run the route tests**
Run:
```bash
npm test -- test/web/client-route.test.js
```
Expected: all existing and new route tests pass.
- [ ] **Step 3: Commit**
```bash
git add test/web/client-route.test.js
git commit -m "test: lock client route display contract"
```
---
### Task 2: Restructure Client Overview Markup
**Files:**
- Modify: `src/web/components/ClientOverviewPage.jsx`
- [ ] **Step 1: Replace the route line with route nodes**
Replace `RouteLine` with:
```jsx
function RouteStrip({ route }) {
const nodes = [
{ label: 'Источник', value: route.path[0], detail: 'приложения Mac' },
{ label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' },
{ label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' },
{ label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' },
];
return (
<div className="client-route-strip">
{nodes.map((node) => (
<div className={`client-route-node ${node.active ? 'active' : ''}`} key={node.label}>
<small>{node.label}</small>
<strong>{node.value}</strong>
<span>{node.detail}</span>
</div>
))}
</div>
);
}
function RoutePath({ route }) {
return (
<div className="client-route-path">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<strong>{item}</strong>
{index < route.path.length - 1 && <span>{'>'}</span>}
</React.Fragment>
))}
</div>
);
}
```
- [ ] **Step 2: Add a mode rail component**
Add:
```jsx
function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) {
const modes = [
{
id: 'gateway',
title: 'Общий gateway',
subtitle: clientSettings?.sharedProxy
? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`
: 'серверная proxy',
onClick: onGateway,
},
{
id: 'vpn',
title: 'Локальный VPN',
subtitle: state?.selectedTag || 'выбрать сервер',
onClick: onVpn,
},
{
id: 'direct',
title: 'Напрямую',
subtitle: 'без VPN',
onClick: onDirect,
},
];
return (
<aside className="client-mode-rail">
<div className="client-section-label">Режим подключения</div>
<div className="client-mode-list">
{modes.map((mode) => (
<button
key={mode.id}
type="button"
className={`client-rail-mode ${setupMode === mode.id ? 'selected' : ''} ${route.mode === mode.id ? 'active' : ''}`}
disabled={busy}
onClick={mode.onClick}
>
<span className="client-mode-dot" />
<span>
<strong>{mode.title}</strong>
<small>{mode.subtitle}</small>
</span>
</button>
))}
</div>
</aside>
);
}
```
- [ ] **Step 3: Replace the top-level JSX**
Use this layout in `ClientOverviewPage`:
```jsx
return (
<div className="client-console">
<ModeRail
route={route}
setupMode={setupMode}
clientSettings={clientSettings}
state={state}
busy={busy}
onGateway={selectGateway}
onVpn={selectVpn}
onDirect={() => {
setSetupMode('direct');
enableDirect();
}}
/>
<section className="client-route-workspace">
<StatusPanel route={route} state={state} />
<RouteStrip route={route} />
<RoutePath route={route} />
<section className="client-mode-panel">
{setupMode === 'gateway' && (
<GatewaySettings settings={clientSettings} busy={busy} onCheck={onCheckSharedProxy} />
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
)}
{setupMode === 'direct' && <DirectSettings busy={busy} onEnable={enableDirect} />}
</section>
</section>
<ProxySettings state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
);
```
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx
git commit -m "refactor: reshape client overview around route console"
```
---
### Task 3: Replace Client Visual System
**Files:**
- Modify: `src/web/styles.css`
- [ ] **Step 1: Replace only the client CSS block**
Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette.
Use these token values for the client block:
```css
.app-body.client-mode {
grid-template-columns: 1fr;
background: oklch(0.965 0.008 232);
}
.client-mode .topbar {
background: oklch(0.978 0.007 232);
border-bottom-color: oklch(0.835 0.018 232);
}
.client-mode .app-main {
max-width: 1320px;
width: 100%;
margin: 0 auto;
padding: 18px;
color: oklch(0.238 0.028 238);
}
.client-console {
min-height: calc(100vh - var(--topbar-h) - 36px);
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
overflow: hidden;
background: oklch(0.986 0.006 232);
border: 1px solid oklch(0.835 0.018 232);
border-radius: 8px;
box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
}
```
- [ ] **Step 2: Add responsive behavior**
Add:
```css
@media (max-width: 1080px) {
.client-console {
grid-template-columns: 220px minmax(0, 1fr);
}
.client-side-panel {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid oklch(0.835 0.018 232);
}
.client-route-strip {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.client-console,
.client-route-strip,
.client-inline-form,
.client-port-row {
grid-template-columns: 1fr;
}
.client-mode-rail {
border-right: 0;
border-bottom: 1px solid oklch(0.835 0.018 232);
}
}
```
- [ ] **Step 3: Verify no banned patterns were introduced**
Run:
```bash
rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css
```
Expected: no matches.
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/styles.css
git commit -m "style: apply light route console client theme"
```
---
### Task 4: Browser Verification
**Files:**
- No file changes expected.
- [ ] **Step 1: Start the dev server**
Run:
```bash
npm run dev -- --host 127.0.0.1 --port 4567
```
Expected: Vite listens on `http://127.0.0.1:4567`.
- [ ] **Step 2: Open client mode with representative state**
Use the browser to open:
```text
http://127.0.0.1:4567
```
Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width.
- [ ] **Step 3: Check mobile width**
Resize to 390px wide.
Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable.
- [ ] **Step 4: Run final verification**
Run:
```bash
npm test
npm run build
git diff --check
```
Expected: all commands pass.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js
git commit -m "feat: redesign client overview as route console"
```
---
## Self-Review
Spec coverage:
- Current UX assessment is captured in `Current Findings`.
- New design direction is captured in `Target Design`.
- Open Design artifact is referenced explicitly.
- Implementation tasks cover route contract, markup, scoped CSS, and browser verification.
Placeholder scan:
- No `TBD`, `TODO`, or unspecified validation steps remain.
Type consistency:
- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`.

View File

@@ -1,12 +1,5 @@
# Windows Client Design # Windows Client Design
> Superseded: this document describes the earlier Node/web-control Windows direction.
> The active Windows direction is a standalone Tauri 2 desktop app under
> `apps/windows-client`, driven by `docs/windows-client-product-tech-brief.md`
> and `docs/goals/windows-modular-client/PLAN.md`.
> Content below is retained for historical context and may contradict the active
> Tauri plan.
## Goal ## Goal
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs. Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.

View File

@@ -1,635 +0,0 @@
# Windows Proxy Client: Product And Technology Brief
Дата: 2026-07-03
Цель документа: описать, как должно выглядеть и работать Windows-приложение для управления proxy/VPN-маршрутизацией приложений, и какой стек лучше использовать для реализации.
Этот документ можно отдать другой модели или команде как исходное ТЗ.
## Коротко
Нужно Windows-приложение, которое разделяет систему на три независимые части:
1. **Control App**: маленькое desktop-приложение для настройки, статуса, профилей, логов и запуска операций.
2. **Proxyfier Layer**: отдельный компонент, который заставляет выбранные Windows-приложения ходить через SOCKS5/HTTP proxy, даже если они сами не умеют proxy.
3. **Local sing-box**: опциональный локальный VPN/proxy runtime. Его можно установить, не устанавливать, остановить, заменить внешним proxy target.
Главный принцип: пользователь не обязан ставить все сразу. Если у него уже есть proxy, ему нужны только Control App + Proxyfier. Если нужен локальный VPN-клиент, он отдельно ставит `sing-box`.
## Как это должно выглядеть
Приложение должно выглядеть как компактная системная утилита, а не как сайт.
Главный экран:
- верхняя строка: общий статус маршрута;
- три карточки компонентов: `Control App`, `Proxyfier`, `Local sing-box`;
- список активных профилей;
- кнопка `Apply changes`;
- короткая лента последних событий.
Пример главного статуса:
```text
Selected apps -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> VPN
```
или:
```text
Selected apps -> ProxiFyre -> Existing proxy 192.168.50.111:8080
```
Если `sing-box` не установлен, это не ошибка. Карточка должна показывать:
```text
Local sing-box
Not installed
Install if you want this PC to run its own local VPN proxy.
```
Если Proxyfier не установлен, профили можно редактировать, но apply должен быть заблокирован:
```text
Proxyfier is required to route selected apps.
Install Proxyfier
```
## Основные экраны
### 1. Overview
Показывает:
- текущий route line;
- статус Control App;
- статус Proxyfier;
- статус Local sing-box;
- активный proxy target;
- сколько приложений сейчас включено в routing;
- последние 5-10 событий.
Действия:
- restart Proxyfier;
- restart local sing-box, если установлен;
- open logs;
- copy diagnostics.
### 2. Profiles
Профиль - главный объект настройки.
Профиль содержит:
- название;
- enabled/disabled;
- proxy target;
- протоколы: TCP, UDP;
- список приложений.
Типы элементов:
- `process`: имя процесса, например `Discord`, `Telegram`, `Code`;
- `folder`: папка, приложение сканирует `.exe` внутри;
- `exe`: конкретный путь к `.exe`.
UI профиля:
- слева список профилей;
- справа детали выбранного профиля;
- поле выбора target;
- кнопки добавления: `Process`, `Folder`, `EXE`;
- preview resolved apps;
- `Save`;
- `Apply changes`.
Важно: пользователь должен видеть понятные исходные элементы, а не только сгенерированный конфиг Proxyfier.
### 3. Targets
Proxy target - это куда Proxyfier отправляет трафик выбранных приложений.
Типы targets:
- `Local sing-box`: `127.0.0.1:1080`, доступен только если local sing-box установлен и запущен;
- `Existing SOCKS5 proxy`: например `127.0.0.1:8080` или `192.168.50.111:8080`;
- `Existing HTTP proxy`, если выбранный proxyfier поддерживает HTTP.
На экране targets:
- список targets;
- проверка соединения;
- имя, host, port, protocol;
- статус last checked;
- кнопка set default.
### 4. Components
Отдельный экран или часть Overview.
Компоненты:
- Control App;
- Proxyfier;
- Local sing-box.
Для каждого:
- installed / not installed;
- running / stopped;
- version;
- path;
- service/task status;
- actions.
Actions должны быть явными:
- `Install`;
- `Repair`;
- `Start`;
- `Stop`;
- `Restart`;
- `Open folder`;
- `View logs`.
Нельзя делать скрытую установку `sing-box` при сохранении профиля.
### 5. Logs / Diagnostics
Должно быть две зоны:
- activity: действия пользователя и результат apply;
- runtime logs: proxyfier logs, sing-box logs, helper logs.
Кнопка `Copy diagnostics` должна собирать:
- версии компонентов;
- paths;
- running status;
- активные profiles;
- targets без секретов;
- последние ошибки;
- путь к сгенерированному proxyfier config.
## Пользовательские сценарии
### Сценарий A: у пользователя уже есть proxy
1. Пользователь устанавливает Control App.
2. Открывает приложение.
3. Видит, что Proxyfier не установлен, а sing-box отсутствует.
4. Нажимает `Install Proxyfier`.
5. Добавляет target `192.168.50.111:8080`.
6. Создает профиль `Discord`.
7. Добавляет process `Discord`.
8. Нажимает `Apply changes`.
9. Приложение генерирует config для Proxyfier и перезапускает proxyfier service.
Результат: Discord ходит через внешний proxy. Local sing-box не нужен.
### Сценарий B: пользователь хочет локальный VPN proxy
1. Пользователь устанавливает Control App.
2. Устанавливает Proxyfier.
3. Устанавливает Local sing-box.
4. Вводит subscription/VLESS link.
5. Выбирает сервер.
6. Local sing-box поднимает SOCKS5/HTTP endpoint на `127.0.0.1:1080`.
7. Профили используют target `Local sing-box`.
Результат: выбранные приложения ходят через локальный sing-box.
### Сценарий C: временно отключить VPN
1. Пользователь открывает профиль.
2. Меняет target с `Local sing-box` на внешний proxy или `Direct/Disabled`.
3. Нажимает `Apply changes`.
Результат: Proxyfier перегенерирован, local sing-box можно остановить отдельно.
## Рекомендуемый стек
### Desktop shell: Tauri 2
Рекомендация: **Tauri 2 + React + TypeScript + Rust backend**.
Почему:
- Tauri ориентирован на маленькие desktop-приложения и использует системный web renderer, поэтому приложение легче Electron.
- Можно писать UI на обычном web stack: React/TypeScript/Vite.
- Backend-часть на Rust хорошо подходит для Windows APIs, файлов, процессов, sidecar binaries и безопасных команд.
- Tauri поддерживает sidecar binaries, но требует явно выдать permissions на запуск sidecar, что полезно для security boundary.
Frontend:
- React;
- TypeScript;
- Vite;
- TanStack Query для загрузки/кэша status/API;
- Zustand или Jotai для локального UI state;
- Zod для валидации JSON-моделей;
- CSS modules или Tailwind. Для этой утилиты лучше сдержанный Windows-like UI, без тяжелой дизайн-системы.
Backend внутри Tauri:
- Rust commands для простых операций;
- отдельный `core` crate с доменной логикой;
- отдельный `windows-helper` binary для elevated/privileged действий.
Не рекомендую начинать с Electron, если нет жесткой причины. Electron проще для web-команды, но тяжелее по размеру и памяти. Для маленькой системной утилиты Tauri подходит лучше.
### Privileged helper
Нужно отделить обычное приложение от операций администратора.
Рекомендуемая модель:
```text
Tauri UI
-> Rust app backend
-> unprivileged status/read operations
-> explicit elevated helper for install/repair/service operations
```
Privileged helper может быть:
- Rust CLI, который запускается elevated только для конкретной операции;
- Rust Windows service/helper, если нужен постоянный privileged agent;
- PowerShell scripts только как thin installer layer, не как основная бизнес-логика.
Для MVP можно сделать проще:
- installers запускаются отдельно от имени администратора;
- Control App работает обычным пользователем;
- service start/stop/restart идет через helper command;
- helper возвращает JSON, UI не парсит текст PowerShell.
Контракт helper:
```json
{
"action": "proxyfier.apply",
"payload": {
"configPath": "C:\\Tools\\ProxiFyre\\app-config.json",
"config": {}
}
}
```
Ответ:
```json
{
"success": true,
"action": "proxyfier.apply",
"changed": true,
"message": "Proxyfier config applied and service restarted"
}
```
Ошибки:
```json
{
"success": false,
"action": "proxyfier.apply",
"error": "Proxyfier service is not installed",
"details": {}
}
```
### Service/runtime management
Для `sing-box` как background runtime:
- использовать `sing-box check` перед применением config;
- хранить config отдельно;
- запускать как Windows service или scheduled task;
- для service wrapper можно использовать WinSW, если не хочется писать собственный Windows service wrapper.
Практичный вариант:
- v1: WinSW wraps `sing-box.exe`;
- v2: собственный Rust service/helper, если понадобится полный контроль.
Control App не должен напрямую владеть процессом `sing-box`. Он должен управлять service/task через helper.
### Local sing-box
`sing-box` - опциональный runtime.
Его роль:
- принять subscription/VLESS/sing-box config;
- поднять локальный mixed SOCKS/HTTP inbound;
- слушать только `127.0.0.1`, например `127.0.0.1:1080`;
- маршрутизировать трафик через выбранный outbound.
Config генерируется из source state приложения и проверяется:
```powershell
sing-box check -c C:\Tools\VpnProxy\sing-box\config.json
```
Local sing-box не должен быть обязательным. Если profile target указывает на внешний proxy, `sing-box` может отсутствовать.
### Proxyfier layer
Рекомендуемый стартовый backend: **ProxiFyre**.
Почему:
- open-source;
- Windows-focused;
- маршрутизирует TCP и UDP;
- работает per-application;
- использует `app-config.json`;
- может работать как Windows Service.
Важное ограничение: ProxiFyre лицензируется как AGPL-3.0. Если продукт должен быть закрытым коммерческим приложением, нужно заранее решить юридический вопрос или сделать adapter layer, чтобы можно было заменить engine на:
- коммерческий Proxifier;
- ProxyBridge;
- собственный WinDivert/NDIS/WFP-based engine;
- другой per-app proxy router.
Интерфейс должен называться не `ProxiFyreConfig`, а шире:
```text
ProxyRouterAdapter
```
Первый adapter:
```text
ProxiFyreAdapter
```
Это позволит поменять engine без переделки UI и профилей.
### Data storage
Для MVP лучше использовать простые JSON-файлы с schema validation.
Причина:
- настройки легко читать и бэкапить;
- можно быстро отлаживать;
- config portable;
- подходит для profile/target/source state.
Рекомендуемые файлы:
```text
C:\ProgramData\VpnProxy\config\profiles.json
C:\ProgramData\VpnProxy\config\targets.json
C:\ProgramData\VpnProxy\config\components.json
C:\ProgramData\VpnProxy\state\activity.json
C:\ProgramData\VpnProxy\state\last-status.json
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
C:\ProgramData\VpnProxy\generated\sing-box-config.json
```
Если нужна большая история событий, статистика трафика или сложные миграции, тогда добавить SQLite:
- `rusqlite` или `sqlx` в Rust;
- миграции;
- таблицы `activity`, `component_status`, `traffic_events`.
Но source of truth для профилей можно оставить JSON даже при наличии SQLite.
### Installer strategy
Нужны три явных installer entrypoints:
```text
Install Control App
Install Proxyfier Layer
Install Local sing-box
```
Они могут быть кнопками в UI, но каждая операция должна быть отдельной и понятной.
CLI/script names:
```text
install-control-app.ps1
install-proxyfier.ps1
install-singbox.ps1
```
Или в packaged app:
```text
VpnProxySetup.exe /component control-app
VpnProxySetup.exe /component proxyfier
VpnProxySetup.exe /component sing-box
```
Каждый installer:
- idempotent;
- делает backup перед overwrite;
- не удаляет чужие файлы без подтверждения;
- проверяет admin rights;
- пишет machine-readable install result;
- не трогает остальные компоненты без явного выбора.
### Security model
Правила:
- UI работает без admin rights.
- Admin elevation только для install/repair/service/config apply, если это реально нужно.
- Local API, если будет, слушает только `127.0.0.1`.
- Лучше использовать Tauri commands / named pipe, чем открытый HTTP port.
- Если нужен loopback HTTP, включить token или origin check.
- Секреты subscription URLs не показывать в diagnostics.
- Generated configs не редактируются вручную из UI.
- Every apply creates backup.
## Архитектура
```text
+-------------------------------+
| Tauri Control App |
| React/TypeScript UI |
+---------------+---------------+
|
v
+-------------------------------+
| Rust App Backend |
| profiles, targets, validation |
| component status aggregation |
+-------+---------------+-------+
| |
v v
+---------------+ +-------------------+
| Proxy Router | | Local sing-box |
| Adapter | | Adapter |
| ProxiFyre v1 | | config + service |
+-------+-------+ +---------+---------+
| |
v v
+---------------+ +-------------------+
| ProxiFyre | | sing-box.exe |
| Windows svc | | Windows svc/task |
+---------------+ +-------------------+
```
## Модель данных
### Profile
```json
{
"id": "discord",
"name": "Discord",
"enabled": true,
"targetId": "local-singbox",
"protocols": ["TCP", "UDP"],
"items": [
{ "type": "process", "value": "Discord" },
{ "type": "folder", "value": "%LOCALAPPDATA%\\Discord", "recursive": true },
{ "type": "exe", "value": "C:\\Games\\Game\\game.exe" }
]
}
```
### Target
```json
{
"id": "local-singbox",
"name": "Local sing-box",
"type": "local",
"protocol": "socks5",
"host": "127.0.0.1",
"port": 1080,
"requiresComponent": "singbox"
}
```
External target:
```json
{
"id": "home-gateway",
"name": "Home gateway",
"type": "external",
"protocol": "socks5",
"host": "192.168.50.111",
"port": 8080
}
```
### Component status
```json
{
"id": "proxyfier",
"name": "Proxyfier",
"installed": true,
"running": true,
"version": "2.3.0",
"path": "C:\\Tools\\ProxiFyre",
"serviceName": "ProxiFyreService",
"problems": [],
"actions": ["restart", "repair", "openLogs"]
}
```
## Apply behavior
Apply должен делать одно понятное действие:
1. Прочитать profiles.
2. Прочитать targets.
3. Проверить, что выбранные targets доступны.
4. Проверить, что Proxyfier установлен.
5. Разрешить folder/exe в process names.
6. Сгенерировать proxyfier config.
7. Сделать backup старого config.
8. Записать новый config.
9. Перезапустить Proxyfier service.
10. Записать activity entry.
Если profile использует `local-singbox`, дополнительно:
- проверить, что `sing-box` установлен;
- проверить, что service running;
- проверить, что `127.0.0.1:1080` отвечает.
Если `local-singbox` не установлен, но profile target внешний, apply должен работать.
## Что не делать
- Не делать глобальную смену Windows proxy settings.
- Не делать `sing-box` обязательным.
- Не смешивать installer и profile apply.
- Не хранить generated ProxiFyre config как source of truth.
- Не привязывать UI напрямую к ProxiFyre, нужен adapter layer.
- Не запускать privileged операции без явного согласия пользователя.
- Не делать большой dashboard с лишней статистикой в первой версии.
## MVP
Самый правильный первый slice:
1. Tauri app shell.
2. Profiles UI.
3. Targets UI.
4. Component status UI.
5. ProxiFyre adapter.
6. External SOCKS5 target.
7. Apply profile -> generate ProxiFyre config -> restart service.
В MVP `sing-box` может быть только карточкой `Not installed / Install`.
После этого добавить:
1. Local sing-box installer.
2. Subscription import.
3. Server selection.
4. Generate sing-box config.
5. Start/stop/restart local sing-box service.
## Acceptance criteria
Приложение считается успешным, если:
- можно установить только Control App;
- можно установить Proxyfier отдельно;
- можно не устанавливать sing-box;
- можно добавить внешний SOCKS5 target;
- можно создать профиль для Discord;
- можно применить профиль;
- generated ProxiFyre config не редактируется пользователем вручную;
- UI показывает, что local sing-box отсутствует, но это не ломает внешний proxy flow;
- после установки sing-box появляется target `Local sing-box`;
- пользователь может переключить профиль с внешнего target на local sing-box.
## Prompt For Another AI
Build a Windows desktop proxy management app.
Use Tauri 2 with React, TypeScript, Vite, and a Rust backend. The app must manage three independent components: the Control App, a proxyfier layer, and optional local sing-box. Do not make sing-box mandatory.
The UI must be a compact Windows utility with these screens: Overview, Profiles, Targets, Components, Logs. Profiles contain process/folder/exe entries and choose a proxy target. Targets can be local sing-box or external SOCKS5/HTTP proxies. Proxyfier is the layer that routes selected apps through the chosen target.
Start with ProxiFyre as the first proxy router adapter, but design an adapter boundary so it can later be replaced. Store source configuration as JSON with schema validation. Generated ProxiFyre and sing-box configs are derived artifacts, not source truth.
Privileged operations must be isolated in an explicit helper/installer flow. The main UI should run without admin rights. Install Control App, Install Proxyfier, and Install Local sing-box must be separate operations. Applying a profile must not silently install missing components.
MVP: external SOCKS5 target + ProxiFyre profile apply. Then add optional local sing-box installation, subscription import, server selection, and local sing-box service control.
## References
- Tauri 2: https://v2.tauri.app/
- Tauri sidecar permissions: https://v2.tauri.app/develop/sidecar/
- sing-box configuration: https://sing-box.sagernet.org/configuration/
- ProxiFyre repository: https://github.com/wiresock/proxifyre
- WinSW service wrapper: https://github.com/winsw/winsw
- Microsoft Windows Service with Worker Service: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service

View File

@@ -5,15 +5,11 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
PROXY_PORT="${PROXY_PORT:-8080}" PROXY_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}" PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
PROXY_FIREWALL="${PROXY_FIREWALL:-true}" PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}" BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache) # Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}" DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
@@ -40,32 +36,13 @@ cleanup_proxy_firewall() {
cleanup_tproxy() { cleanup_tproxy() {
log "cleanup tproxy rules" log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t nat -X "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
# ipset не чистим при завершении — TTL сам истечёт # ipset не чистим при завершении — TTL сам истечёт
} }
enable_ip_forwarding() {
log "enable IPv4 forwarding for source bypass"
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
return
fi
if command -v sysctl >/dev/null 2>&1; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
fi
}
setup_direct_bypass_set() { setup_direct_bypass_set() {
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
export DIRECT_BYPASS_CACHE export DIRECT_BYPASS_CACHE
@@ -99,32 +76,14 @@ setup_proxy_firewall() {
setup_tproxy() { setup_tproxy() {
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}" log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
cleanup_tproxy cleanup_tproxy
enable_ip_forwarding
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE" ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
ipt -t mangle -N "$TPROXY_CHAIN" ipt -t mangle -N "$TPROXY_CHAIN"
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box) # Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
for cidr in $BYPASS_CIDRS; do
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
done
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
done
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box. # Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.

View File

@@ -47,10 +47,6 @@ TPROXY_PORT=7895
TPROXY_MARK=1 TPROXY_MARK=1
TPROXY_TABLE=100 TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY TPROXY_CHAIN=VPN_PROXY_TPROXY
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
TPROXY_BYPASS_SOURCE_CIDRS=
ROUTING_RU_DIRECT=true ROUTING_RU_DIRECT=true
LOG_LEVEL=info LOG_LEVEL=info
EOF EOF

View File

@@ -59,6 +59,15 @@ ask_proxy_port() {
printf '%s\n' "$DEFAULT_PROXY_PORT" printf '%s\n' "$DEFAULT_PROXY_PORT"
} }
port_range_end() {
local start="$1"
local end="$((start + 10))"
if [ "$end" -gt 65535 ]; then
end=65535
fi
printf '%s\n' "$end"
}
published_port_conflicts() { published_port_conflicts() {
local port="$1" local port="$1"
local line local line
@@ -73,7 +82,18 @@ published_port_conflicts() {
} }
proxy_port_conflicts() { proxy_port_conflicts() {
published_port_conflicts "$1" local start="$1"
local end
local port
local conflicts
end="$(port_range_end "$start")"
for port in $(seq "$start" "$end"); do
conflicts="$(published_port_conflicts "$port")"
if [ -n "$conflicts" ]; then
printf 'port %s: %s\n' "$port" "$conflicts"
fi
done
} }
assert_proxy_port_available() { assert_proxy_port_available() {
@@ -85,8 +105,8 @@ assert_proxy_port_available() {
return 0 return 0
fi fi
printf '[vpn-proxy-client] proxy port %s is already used:\n%s\n' \ printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \
"$port" "$conflicts" >&2 "$port" "$(port_range_end "$port")" "$conflicts" >&2
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container" die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
} }
@@ -150,8 +170,8 @@ choose_ui_port() {
} }
assert_ui_outside_proxy_range() { assert_ui_outside_proxy_range() {
if [ "$UI_PORT" = "$PROXY_PORT" ]; then if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then
die "UI port ${UI_PORT} overlaps proxy port" die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}"
fi fi
} }
@@ -161,7 +181,7 @@ wait_for_client_ui() {
local attempt local attempt
for attempt in $(seq 1 30); do for attempt in $(seq 1 30); do
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then if curl -fsS "$ui_url" >/dev/null 2>&1; then
return 0 return 0
fi fi
sleep 1 sleep 1
@@ -234,7 +254,7 @@ fi
PROXY_PORT="$(ask_proxy_port)" PROXY_PORT="$(ask_proxy_port)"
assert_proxy_port_available "$PROXY_PORT" assert_proxy_port_available "$PROXY_PORT"
PROXY_PORT_END="$PROXY_PORT" PROXY_PORT_END="$(port_range_end "$PROXY_PORT")"
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}" UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
UI_PORT="${UI_PORT:-3456}" UI_PORT="${UI_PORT:-3456}"
UI_PORT="$(choose_ui_port "$UI_PORT")" UI_PORT="$(choose_ui_port "$UI_PORT")"
@@ -248,7 +268,7 @@ set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
set_env_value PROXY_PORT "$PROXY_PORT" set_env_value PROXY_PORT "$PROXY_PORT"
log "UI port: http://127.0.0.1:${UI_PORT}" log "UI port: http://127.0.0.1:${UI_PORT}"
log "proxy port: 127.0.0.1:${PROXY_PORT}" log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})"
log "building and starting Docker client" log "building and starting Docker client"
docker compose -f "$COMPOSE_FILE" up -d --build docker compose -f "$COMPOSE_FILE" up -d --build
@@ -263,7 +283,7 @@ UI:
Proxy: Proxy:
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT} HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it. UI can switch proxy port within the Docker-published ${PROXY_PORT}-${PROXY_PORT_END} range.
Useful commands: Useful commands:
cd ~/.vpn-proxy-client cd ~/.vpn-proxy-client

View File

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

View File

@@ -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

View 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
}

View 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"

View File

@@ -1,34 +1,18 @@
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 parsePort = (value, fallback) => { const rawAppMode = String(process.env.APP_MODE || "gateway").toLowerCase();
const parsed = Number.parseInt(value, 10); const appMode = ["gateway", "client", "windows"].includes(rawAppMode)
return Number.isInteger(parsed) ? parsed : fallback; ? rawAppMode
}; : "gateway";
const proxyPort = parsePort(process.env.PROXY_PORT, 8080);
const clientProxyPortStart = parsePort(
process.env.CLIENT_PROXY_PORT_START,
proxyPort,
);
const clientProxyPortEnd = parsePort(
process.env.CLIENT_PROXY_PORT_END,
clientProxyPortStart,
);
export const settings = { export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway", appMode,
port: parsePort(process.env.PORT, 3456), port: Number(process.env.PORT || 3456),
proxyPort, proxyPort: Number(process.env.PROXY_PORT || 8080),
clientProxyPortStart, clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
clientProxyPortEnd, clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyPort: Number(process.env.TPROXY_PORT || 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
tproxySourceBypassChain:
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
tproxySourceForwardChain:
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
tproxySourceNatChain:
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir, dataDir,
distDir: process.env.DIST_DIR || "/app/dist", distDir: process.env.DIST_DIR || "/app/dist",
@@ -42,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",
}; };

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { settings } from "./config.js"; import { settings } from "./config.js";
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]); export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]); export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
export const DEFAULT_DEVICE_MODE = "vpn"; export const DEFAULT_DEVICE_MODE = "vpn";
export const DEFAULT_PROXY_MODE = "vpn"; export const DEFAULT_PROXY_MODE = "vpn";
@@ -27,6 +27,7 @@ function writeJson(filePath, value) {
function normalizeDeviceMode(mode, fallback = "rules") { function normalizeDeviceMode(mode, fallback = "rules") {
const value = String(mode || "").trim().toLowerCase(); const value = String(mode || "").trim().toLowerCase();
if (value === "bypass") return "direct";
return DEVICE_MODES.has(value) ? value : fallback; return DEVICE_MODES.has(value) ? value : fallback;
} }

View File

@@ -25,12 +25,16 @@ import {
buildSharedProxyInfo, buildSharedProxyInfo,
checkSharedProxyGateway, checkSharedProxyGateway,
} from "./sharedProxy.js"; } from "./sharedProxy.js";
import {
sourceBypassCidrs,
syncTproxySourceBypass,
} from "./tproxySourceBypass.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;
@@ -607,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,
@@ -638,7 +644,17 @@ function publicState() {
directBypassCount, directBypassCount,
directBypassEnabled: DIRECT_BYPASS_CACHE, directBypassEnabled: DIRECT_BYPASS_CACHE,
directBypassAvailable: IPSET_AVAILABLE, directBypassAvailable: IPSET_AVAILABLE,
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles), windows:
settings.appMode === "windows"
? {
profiles: summarizeProfiles(
readWindowsProfiles(),
windowsTargets,
),
targets: windowsTargets,
activity: readWindowsActivity().slice(-20).reverse(),
}
: null,
...rest, ...rest,
}; };
} }
@@ -691,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) {
@@ -814,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) });
} }
@@ -975,13 +1140,6 @@ async function handleApi(req, res) {
devices: body.devices, devices: body.devices,
}; };
const profiles = writeDeviceProfiles(input); const profiles = writeDeviceProfiles(input);
const sourceBypassResult = syncTproxySourceBypass(profiles);
if (!sourceBypassResult.success) {
pushLog(
"warning",
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
);
}
const prevState = readJson(settings.statePath, {}); const prevState = readJson(settings.statePath, {});
const devicesUpdatedAt = new Date().toISOString(); const devicesUpdatedAt = new Date().toISOString();
writeJson(settings.statePath, { writeJson(settings.statePath, {
@@ -991,8 +1149,6 @@ async function handleApi(req, res) {
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
...profiles, ...profiles,
sourceBypassCidrs: sourceBypassCidrs(profiles),
sourceBypassResult,
devicesUpdatedAt, devicesUpdatedAt,
}); });
} }
@@ -1555,14 +1711,6 @@ process.on("SIGINT", async () => {
process.exit(0); process.exit(0);
}); });
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
if (!sourceBypassStartup.success) {
pushLog(
"warning",
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
);
}
// При старте пробуем подхватить уже запущенный sing-box // При старте пробуем подхватить уже запущенный sing-box
const existingPid = readSingboxPid(); const existingPid = readSingboxPid();
if (existingPid && isPidAlive(existingPid)) { if (existingPid && isPidAlive(existingPid)) {

View File

@@ -159,21 +159,6 @@ export function matchRoute(target, customRules, options = {}) {
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp), device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
); );
if (
inbound === TPROXY_INBOUND &&
matchedDevice &&
matchedDevice.mode === "bypass"
) {
return {
matched: "kernel-bypass",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name} -> bypass TProxy`,
outbound: "direct",
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
};
}
// 1. private IP → direct // 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) { if (target.ip && isPrivateIp(target.ip)) {
return { return {

View File

@@ -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,16 +340,19 @@ 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",
...(clientMode ? {} : { auto_detect_interface: true }), auto_detect_interface: true,
}, },
}; };
} }

View File

@@ -1,124 +0,0 @@
import { spawnSync } from "node:child_process";
import { settings } from "./config.js";
import { deviceCidrs, normalizeCidr } from "./devices.js";
const DEFAULT_NAT_BYPASS_CIDRS =
"0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4";
function splitCidrs(value) {
return String(value || "")
.split(/[\s,]+/)
.map((item) => normalizeCidr(item))
.filter(Boolean);
}
function unique(list) {
return [...new Set(list)];
}
export function sourceBypassCidrs(
profiles,
envCidrs = process.env.TPROXY_BYPASS_SOURCE_CIDRS || "",
) {
return unique([
...splitCidrs(envCidrs),
...deviceCidrs(profiles?.devices || [], "bypass"),
]);
}
export function buildSourceBypassIptablesCommands(
cidrs,
{
chain = settings.tproxySourceBypassChain,
forwardChain = settings.tproxySourceForwardChain,
natChain = settings.tproxySourceNatChain,
natBypassCidrs = splitCidrs(
process.env.BYPASS_CIDRS || DEFAULT_NAT_BYPASS_CIDRS,
),
} = {},
) {
return [
["-w", "-t", "mangle", "-F", chain],
["-w", "-F", forwardChain],
["-w", "-t", "nat", "-F", natChain],
...cidrs.map((cidr) => [
"-w",
"-t",
"mangle",
"-A",
chain,
"-s",
cidr,
"-j",
"ACCEPT",
]),
...cidrs.flatMap((cidr) => [
["-w", "-A", forwardChain, "-s", cidr, "-j", "ACCEPT"],
[
"-w",
"-A",
forwardChain,
"-d",
cidr,
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
]),
...natBypassCidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-d",
cidr,
"-j",
"RETURN",
]),
...cidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-s",
cidr,
"-j",
"MASQUERADE",
]),
];
}
export function syncTproxySourceBypass(profiles, options = {}) {
if (settings.appMode !== "gateway") {
return { success: true, skipped: true, cidrs: [] };
}
const cidrs = sourceBypassCidrs(
profiles,
options.envCidrs ?? process.env.TPROXY_BYPASS_SOURCE_CIDRS,
);
const commands = buildSourceBypassIptablesCommands(cidrs, options);
for (const args of commands) {
const result = spawnSync("iptables", args, {
encoding: "utf8",
timeout: 1000,
});
if (result.error || result.status !== 0) {
return {
success: false,
cidrs,
error:
result.error?.message ||
(result.stderr || result.stdout || "iptables command failed").trim(),
};
}
}
return { success: true, cidrs };
}

View 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();

View 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,
};
}

View File

@@ -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(() => () => {
@@ -224,19 +228,7 @@ function App() {
proxyDefaultMode: data.proxyDefaultMode || 'vpn', proxyDefaultMode: data.proxyDefaultMode || 'vpn',
devices: data.devices || [], devices: data.devices || [],
}); });
setState((prev) => prev ? { setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
...prev,
devicesUpdatedAt: data.devicesUpdatedAt,
sourceBypassCidrs: data.sourceBypassCidrs,
} : prev);
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
pushToast({
kind: 'warning',
title: 'Bypass сохранён, но не применён',
message: data.sourceBypassResult.error,
duration: 7000,
});
}
} catch (err) { } catch (err) {
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message }); pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
} }
@@ -393,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(
@@ -421,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}
@@ -459,7 +455,7 @@ function App() {
/> />
) )
)} )}
{page === 'servers' && !isClientMode && ( {page === 'servers' && !isClientMode && !isWindowsMode && (
<ServersPage <ServersPage
state={state} state={state}
servers={servers} servers={servers}
@@ -475,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}
@@ -509,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'}`} />
@@ -534,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" />
@@ -551,7 +547,7 @@ function App() {
)} )}
</main> </main>
{!isClientMode && ( {!isClientMode && !isWindowsMode && (
<StatusPane <StatusPane
state={state} state={state}
busy={busy} busy={busy}

View File

@@ -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" }),
}; };

View File

@@ -203,7 +203,6 @@ function ProxySettings({ state, settings, busy, onSave }) {
const parsed = Number.parseInt(draftPort, 10); const parsed = Number.parseInt(draftPort, 10);
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end; const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
const dirty = !invalid && parsed !== port; const dirty = !invalid && parsed !== port;
const singlePublishedPort = range.start === range.end;
return ( return (
<aside className="client-side-panel"> <aside className="client-side-panel">
@@ -223,20 +222,17 @@ function ProxySettings({ state, settings, busy, onSave }) {
min={range.start} min={range.start}
max={range.end} max={range.end}
value={draftPort} value={draftPort}
disabled={singlePublishedPort}
onChange={(e) => setDraftPort(e.target.value)} onChange={(e) => setDraftPort(e.target.value)}
/> />
<button <button
className="btn btn-secondary" className="btn btn-secondary"
disabled={busy || singlePublishedPort || !dirty} disabled={busy || !dirty}
onClick={() => onSave({ ...settings, proxyPort: parsed })} onClick={() => onSave({ ...settings, proxyPort: parsed })}
> >
Save Save
</button> </button>
</div> </div>
<small className={invalid ? 'field-error' : 'field-hint'}> <small className={invalid ? 'field-error' : 'field-hint'}>{range.start}{range.end}</small>
{singlePublishedPort ? 'Порт задаётся установщиком' : `${range.start}${range.end}`}
</small>
</div> </div>
</aside> </aside>
); );

View File

@@ -19,7 +19,6 @@ const OUTBOUND_KIND = {
}; };
const DEVICE_MODES = { const DEVICE_MODES = {
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' }, direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' }, vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' }, rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
@@ -29,7 +28,6 @@ const DEVICE_MODES = {
function DeviceModeSelect({ value, onChange }) { function DeviceModeSelect({ value, onChange }) {
return ( return (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}> <select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="bypass">bypass TProxy</option>
<option value="direct">direct</option> <option value="direct">direct</option>
<option value="vpn">VPN</option> <option value="vpn">VPN</option>
<option value="rules">default</option> <option value="rules">default</option>
@@ -48,7 +46,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
<div className="card-header"> <div className="card-header">
<div> <div>
<h2>Устройства</h2> <h2>Устройства</h2>
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы fallback после global rules.</small> <small className="muted">Global rules применяются первыми. Эти значения fallback после них.</small>
</div> </div>
<div className="btn-group"> <div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}> <label className="field" style={{ minWidth: 180, margin: 0 }}>

View File

@@ -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">

View File

@@ -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>
); );

View 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>-&gt;</b><span>ProxiFyre</span><b>-&gt;</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>
);
}

View File

@@ -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;

View File

@@ -1,45 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function withEnv(patch, fn) {
const previous = {};
for (const key of Object.keys(patch)) {
previous[key] = process.env[key];
if (patch[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = patch[key];
}
}
try {
return await fn();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("client proxy range defaults to the single configured proxy port", async () => {
await withEnv(
{
PROXY_PORT: "8082",
CLIENT_PROXY_PORT_START: "8082",
CLIENT_PROXY_PORT_END: undefined,
},
async () => {
const { settings } = await import(
`../../src/server/config.js?single-proxy-port=${Date.now()}`
);
assert.equal(settings.proxyPort, 8082);
assert.equal(settings.clientProxyPortStart, 8082);
assert.equal(settings.clientProxyPortEnd, 8082);
},
);
});

View File

@@ -1,167 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
deviceCidrs,
normalizeDeviceProfiles,
} = await import("../../src/server/devices.js");
const { matchRoute } = await import("../../src/server/routeMatcher.js");
const {
sourceBypassCidrs,
buildSourceBypassIptablesCommands,
} = await import("../../src/server/tproxySourceBypass.js");
const { settings } = await import("../../src/server/config.js");
test("default source bypass chain name fits iptables chain length limit", () => {
assert.equal(settings.tproxySourceBypassChain, "VPN_PROXY_SRC_BYPASS");
assert.equal(settings.tproxySourceForwardChain, "VPN_PROXY_FWD_BYPASS");
assert.equal(settings.tproxySourceNatChain, "VPN_PROXY_NAT_BYPASS");
assert.ok(settings.tproxySourceBypassChain.length <= 28);
assert.ok(settings.tproxySourceForwardChain.length <= 28);
assert.ok(settings.tproxySourceNatChain.length <= 28);
});
test("device profiles preserve bypass mode for kernel-level TProxy bypass", () => {
const profiles = normalizeDeviceProfiles({
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
});
assert.equal(profiles.devices[0].mode, "bypass");
assert.deepEqual(deviceCidrs(profiles.devices, "bypass"), [
"192.168.50.25/32",
]);
});
test("route checker reports transparent bypass before sing-box rules", () => {
const result = matchRoute(
{
host: "example.com",
ip: "93.184.216.34",
sourceIp: "192.168.50.25",
inbound: "tproxy-in",
},
[
{
id: "vpn-all",
enabled: true,
name: "VPN all",
domains: ["example.com"],
outbound: "vpn",
},
],
{
vpnTag: "test-vpn",
deviceProfiles: {
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
},
},
);
assert.equal(result.matched, "kernel-bypass");
assert.equal(result.ruleName, "PC -> bypass TProxy");
assert.equal(result.outbound, "direct");
});
test("source bypass sync combines env CIDRs and bypass-mode devices", () => {
const cidrs = sourceBypassCidrs(
{
devices: [
{ enabled: true, ip: "192.168.50.25", mode: "bypass" },
{ enabled: false, ip: "192.168.50.26", mode: "bypass" },
{ enabled: true, ip: "192.168.50.27", mode: "direct" },
],
},
"192.168.50.30/32",
);
assert.deepEqual(cidrs, ["192.168.50.30/32", "192.168.50.25/32"]);
});
test("source bypass iptables commands use ACCEPT inside the managed subchain", () => {
assert.deepEqual(
buildSourceBypassIptablesCommands(["192.168.50.25/32"], {
chain: "VPN_PROXY_SOURCE_BYPASS",
forwardChain: "VPN_PROXY_FWD_BYPASS",
natChain: "VPN_PROXY_NAT_BYPASS",
natBypassCidrs: ["10.0.0.0/8"],
}),
[
["-w", "-t", "mangle", "-F", "VPN_PROXY_SOURCE_BYPASS"],
["-w", "-F", "VPN_PROXY_FWD_BYPASS"],
["-w", "-t", "nat", "-F", "VPN_PROXY_NAT_BYPASS"],
[
"-w",
"-t",
"mangle",
"-A",
"VPN_PROXY_SOURCE_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-d",
"192.168.50.25/32",
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-d",
"10.0.0.0/8",
"-j",
"RETURN",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"MASQUERADE",
],
],
);
});

View File

@@ -1,122 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const ROOT = path.resolve(import.meta.dirname, "../..");
const ENTRYPOINT = path.join(ROOT, "entrypoint.sh");
function writeExecutable(filePath, contents) {
fs.writeFileSync(filePath, contents, { mode: 0o755 });
}
test("entrypoint bypasses configured source CIDRs before TProxy interception", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-entrypoint-"));
const commandLog = path.join(tmp, "commands.log");
writeExecutable(
path.join(tmp, "iptables"),
`#!/usr/bin/env bash
printf 'iptables %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "ip"),
`#!/usr/bin/env bash
printf 'ip %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "ipset"),
`#!/usr/bin/env bash
printf 'ipset %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "sysctl"),
`#!/usr/bin/env bash
printf 'sysctl %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "node"),
`#!/usr/bin/env bash
printf 'node %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
const result = spawnSync("bash", [ENTRYPOINT], {
cwd: ROOT,
env: {
...process.env,
PATH: `${tmp}${path.delimiter}${process.env.PATH}`,
COMMAND_LOG: commandLog,
TPROXY_BYPASS_SOURCE_CIDRS: "192.168.50.25/32 192.168.50.26/32",
DIRECT_BYPASS_CACHE: "true",
BYPASS_CIDRS: "10.0.0.0/8",
},
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const commands = fs.readFileSync(commandLog, "utf8").trim().split("\n");
const sourceBypassIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_SRC_BYPASS -s 192.168.50.25/32 -j ACCEPT",
),
);
const secondSourceBypassIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_SRC_BYPASS -s 192.168.50.26/32 -j ACCEPT",
),
);
const sourceBypassJumpIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_TPROXY -j VPN_PROXY_SRC_BYPASS",
),
);
const directCacheIndex = commands.findIndex((line) =>
line.includes("-m set --match-set vpn_direct_bypass dst -j RETURN"),
);
const tproxyIndex = commands.findIndex((line) =>
line.includes("-p tcp -j TPROXY --on-port 7895"),
);
const ipForwardIndex = commands.findIndex((line) =>
line.includes("sysctl -w net.ipv4.ip_forward=1"),
);
const forwardAcceptIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -A VPN_PROXY_FWD_BYPASS -s 192.168.50.25/32 -j ACCEPT",
),
);
const forwardReturnIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -A VPN_PROXY_FWD_BYPASS -d 192.168.50.25/32 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT",
),
);
const natMasqueradeIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t nat -A VPN_PROXY_NAT_BYPASS -s 192.168.50.25/32 -j MASQUERADE",
),
);
assert.notEqual(sourceBypassIndex, -1);
assert.notEqual(secondSourceBypassIndex, -1);
assert.notEqual(sourceBypassJumpIndex, -1);
assert.notEqual(directCacheIndex, -1);
assert.notEqual(tproxyIndex, -1);
assert.notEqual(ipForwardIndex, -1);
assert.notEqual(forwardAcceptIndex, -1);
assert.notEqual(forwardReturnIndex, -1);
assert.notEqual(natMasqueradeIndex, -1);
assert.ok(sourceBypassJumpIndex < directCacheIndex);
assert.ok(sourceBypassJumpIndex < tproxyIndex);
});

View File

@@ -44,7 +44,6 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => {
const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(config.route.rule_set, []); assert.deepEqual(config.route.rule_set, []);
assert.equal(config.route.auto_detect_interface, undefined);
assert.deepEqual(config.route.rules, [ assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-vpn" }, { inbound: ["mixed-in"], outbound: "test-vpn" },
]); ]);
@@ -83,7 +82,7 @@ test("client home bypass can build direct proxy without local VPN", () => {
]); ]);
}); });
test("client mode ignores saved proxy port outside the published single port", () => { test("client mode uses selected proxy port from client settings", () => {
fs.rmSync(clientSettingsPath, { force: true }); fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync( fs.writeFileSync(
clientSettingsPath, clientSettingsPath,
@@ -92,7 +91,7 @@ test("client mode ignores saved proxy port outside the published single port", (
const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.equal(config.inbounds[0].listen_port, 8080); assert.equal(config.inbounds[0].listen_port, 8085);
assert.deepEqual(config.route.rules, [ assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-vpn" }, { inbound: ["mixed-in"], outbound: "test-vpn" },
]); ]);

View 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",
]);
});

View 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"]);
});

View 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/,
);
});

View 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: [],
});
});