Compare commits
13 Commits
codex-wind
...
d3b7f0d613
| Author | SHA1 | Date | |
|---|---|---|---|
| d3b7f0d613 | |||
| efa46d1ee5 | |||
| 149bb999dc | |||
| 288acbf0c8 | |||
| b45dd2ae05 | |||
| c5bdb10445 | |||
| 7dbf786c56 | |||
| 59f2264a2e | |||
| a0f41baa36 | |||
| c3d3aaa699 | |||
| 301b76c03e | |||
| ab6de6996f | |||
| 0092ec4cde |
@@ -1,8 +1,8 @@
|
||||
PORT=3456
|
||||
APP_MODE=gateway
|
||||
CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT_START=8080
|
||||
CLIENT_PROXY_PORT_END=8090
|
||||
CLIENT_PROXY_PORT_START=8082
|
||||
CLIENT_PROXY_PORT_END=8082
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
|
||||
142
.interface-design/system.md
Normal file
142
.interface-design/system.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Windows Client Interface System
|
||||
|
||||
Дата: 2026-07-08
|
||||
|
||||
## Направление
|
||||
|
||||
Это максимально простая Windows-утилита управления службами и их состояниями. Она не должна ощущаться как VPN-dashboard, SaaS-панель или маркетинговый клиент. Главные глаголы интерфейса:
|
||||
|
||||
- проверить состояние;
|
||||
- установить компонент;
|
||||
- удалить компонент;
|
||||
- запустить службу;
|
||||
- остановить службу;
|
||||
- обновить/перепроверить состояние;
|
||||
- добавить приложение/путь;
|
||||
- применить конфигурацию только когда prerequisites готовы.
|
||||
|
||||
Интерфейс должен быть плотным, спокойным, системным и предсказуемым. Пользователь открывает его не для анализа статистики, а чтобы быстро понять "что сейчас установлено, что запущено, что сломано и какую одну кнопку нажать дальше".
|
||||
|
||||
## Домен
|
||||
|
||||
Ключевые понятия: Windows service, installed/running/stopped/missing, install/uninstall, helper/elevation, ProxiFyre, Local sing-box, selected apps, endpoint, generated config, logs, diagnostics, readiness blockers.
|
||||
|
||||
Цветовой мир: темная Windows-оболочка, service console, terminal black, muted slate panels, driver/service green, warning amber, blocked red, focus/navigation blue.
|
||||
|
||||
Сигнатура продукта: `Service Control Row` - компактная строка компонента, где слева состояние службы, в центре человекочитаемый статус и путь/деталь, справа ровно одно главное действие плюс меню дополнительных действий. Эта строка должна быть повторяемым паттерном для ProxiFyre, Local sing-box и будущих компонентов.
|
||||
|
||||
## Визуальная модель
|
||||
|
||||
- Основной layout: fixed header, fixed tabs, scrollable work area, adaptive log/status area.
|
||||
- Основная композиция: не больше одного главного действия на компонент или экран.
|
||||
- Summary должен отвечать "готово / не готово / что сделать дальше", а не показывать длинный dashboard.
|
||||
- Route chain полезен, но он вторичен к service-control модели. Он должен объяснять эффект состояний служб, а не превращать приложение в карту сети.
|
||||
|
||||
## Depth And Surfaces
|
||||
|
||||
Стратегия глубины: borders-only + subtle surface shifts. Без тяжелых shadows.
|
||||
|
||||
- `canvas`: `#101216` - фон приложения.
|
||||
- `surface`: `#131720` - обычные панели.
|
||||
- `surface-raised`: `#151923` - service rows и важные панели.
|
||||
- `surface-control`: `#242a35` - neutral buttons.
|
||||
- `surface-inset`: `#0d1016` - inputs и read-only inset fields.
|
||||
- `border`: `#2b3342`.
|
||||
- `border-strong`: `#343b49`.
|
||||
- `focus`: `#3b82f6`.
|
||||
|
||||
Радиусы: 4px для buttons, inputs, rows, panels; 999px только для pills/dots. Карточки и панели не должны становиться мягкими/крупно-скругленными.
|
||||
|
||||
Spacing base: 4px. Основные значения: 6, 8, 10, 12, 14, 18. Для плотной утилиты 20+ использовать редко, только между большими группами.
|
||||
|
||||
## Typography
|
||||
|
||||
Шрифт: Inter/system stack как сейчас.
|
||||
|
||||
Плотная шкала:
|
||||
|
||||
- caption: 12px / 500-700 / muted;
|
||||
- body: 14px / 400-500 / secondary;
|
||||
- control: 14px / 700 / primary;
|
||||
- section title: 16px / 700;
|
||||
- screen title: 18px / 650-750;
|
||||
- status title: 22px / 700 only for primary readiness state.
|
||||
|
||||
Иерархия должна строиться весом и цветом больше, чем размером. Letter spacing держать `0`.
|
||||
|
||||
## Компонентная база
|
||||
|
||||
### Button
|
||||
|
||||
Все кнопки должны идти через общий компонент и общие variants:
|
||||
|
||||
- `primary`: главное безопасное действие текущего блока. Зеленый использовать только когда действие реально применимо и готово к выполнению.
|
||||
- `neutral`: refresh, open, cancel, secondary action.
|
||||
- `add`: добавление процесса, EXE, папки, target. Иконка + tooltip; текстовая кнопка только когда без текста смысл неясен.
|
||||
- `danger`: stop/uninstall/delete. Не смешивать с neutral.
|
||||
- `icon`: квадрат 40-44px, только иконка, обязательны `aria-label`, `title` или tooltip.
|
||||
|
||||
Button states обязательны: default, hover, active, focus-visible, disabled, loading.
|
||||
|
||||
Loading state не должен выглядеть как зависание: показывать spinner/progress label, менять текст на действие в процессе ("Проверяю", "Устанавливаю", "Останавливаю"), блокировать повторный запуск.
|
||||
|
||||
### Service Control Row
|
||||
|
||||
Повторяемый компонент для служб:
|
||||
|
||||
- left: status dot/icon (`checking`, `missing`, `installed`, `running`, `stopped`, `error`);
|
||||
- center: title + short status + optional path/details;
|
||||
- right: primary action (`Установить`, `Запустить`, `Остановить`, `Обновить`) + overflow menu;
|
||||
- expanded area: setup checklist, diagnostics, paths, generated config.
|
||||
|
||||
В строке не должно быть двух конкурирующих primary actions.
|
||||
|
||||
### Tabs
|
||||
|
||||
Tabs должны быть единым компонентом:
|
||||
|
||||
- role tablist/tab/tabpanel;
|
||||
- arrow-key navigation;
|
||||
- active indicator 3px blue;
|
||||
- height 46px desktop, не меньше 40px narrow;
|
||||
- transition 180-240ms только `opacity` + `transform`;
|
||||
- no layout shift при переключении.
|
||||
|
||||
### Status And Logs
|
||||
|
||||
Status surface должен показывать человеческий текст первым, raw details вторым уровнем.
|
||||
|
||||
- Preview/native command errors: friendly summary in dock, raw error in details.
|
||||
- `aria-live` объявляет только короткий статус.
|
||||
- Narrow width: dock collapses into compact toast/details control.
|
||||
|
||||
### Forms And Inputs
|
||||
|
||||
Inputs are inset:
|
||||
|
||||
- background `surface-inset`;
|
||||
- border `border-strong`;
|
||||
- focus ring 1px `focus`;
|
||||
- validation appears directly below or beside the field;
|
||||
- invalid state disables dependent action and explains exact accepted format.
|
||||
|
||||
### Motion
|
||||
|
||||
Motion должна показывать, что интерфейс живой, но не тормозить повторяющиеся операции.
|
||||
|
||||
- Button press: 100-140ms, `transform: scale(0.98)`.
|
||||
- Tab switch: 180-240ms, `opacity` + `translateX` 12-24px.
|
||||
- Popover/menu: 150-180ms, `opacity` + `scale(0.97 -> 1)`.
|
||||
- Service checking/installing: subtle spinner or border trace, but not decorative glow everywhere.
|
||||
- Respect `prefers-reduced-motion`.
|
||||
|
||||
Не использовать `transition: all`.
|
||||
|
||||
## Implementation Priorities
|
||||
|
||||
1. Создать shared UI layer: `Button`, `IconButton`, `Tabs`, `ServiceControlRow`, `StatusPill`, `LogDock`, `Field`, `ActionMenu`.
|
||||
2. Перевести текущие кнопки на variants, чтобы "обновить", "добавить", "установить", "удалить", "применить" выглядели и вели себя стабильно.
|
||||
3. Ввести readiness blockers для apply actions.
|
||||
4. Свести все анимации к общим duration/easing tokens.
|
||||
5. Проверить keyboard flow и responsive snapshots после каждого крупного UI изменения.
|
||||
|
||||
@@ -40,7 +40,7 @@ RUN chmod +x /entrypoint.client.sh \
|
||||
|
||||
ENV APP_MODE=client \
|
||||
PORT=3456 \
|
||||
PROXY_PORT=8080 \
|
||||
PROXY_PORT=8082 \
|
||||
PROXY_BIND_IP=0.0.0.0 \
|
||||
DATA_DIR=/var/lib/vpn-proxy \
|
||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||
@@ -49,6 +49,6 @@ ENV APP_MODE=client \
|
||||
ROUTING_RU_DIRECT=true \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 3456 8080
|
||||
EXPOSE 3456 8082
|
||||
|
||||
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||
|
||||
35
PRODUCT.md
Normal file
35
PRODUCT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
macOS users without networking or Docker expertise. They open the client only to add a VPN subscription, choose a server, and turn the connection on or off.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Provide a small, dependable control surface for the Docker-based macOS VPN client. Success means the current connection state is obvious and the full everyday workflow fits on one screen.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and controls for gateway or server administration.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- One screen, one primary action.
|
||||
- Use plain language and hide implementation details.
|
||||
- Make connection state unmistakable without relying on color alone.
|
||||
- Prefer native controls and predictable macOS behavior.
|
||||
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||
- Keep advanced and server-only features out of the client path.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||
58
README.md
58
README.md
@@ -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`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8082` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
|
||||
|
||||
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
|
||||
|
||||
@@ -53,38 +53,21 @@ docker compose -f docker-compose.client.yml logs -f
|
||||
docker compose -f docker-compose.client.yml restart
|
||||
```
|
||||
|
||||
## Windows: app proxy client
|
||||
## Windows Desktop Client
|
||||
|
||||
Windows mode restores the native workflow for Discord, Vesktop, games, and other apps that do not expose proxy settings.
|
||||
|
||||
Run PowerShell 7 as Administrator. While this branch is being tested, install from `codex-windows-client`:
|
||||
The Windows desktop client has been split out of this repository. Continue
|
||||
Windows-specific work in the sibling repository:
|
||||
|
||||
```powershell
|
||||
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/codex-windows-client/scripts/install-windows-client.ps1 | iex
|
||||
cd D:\repos\ProxyWarden
|
||||
```
|
||||
|
||||
Installer modes:
|
||||
This repository keeps the gateway and Docker client runtime; it no longer owns
|
||||
the Tauri Windows app, ProxiFyre adapter, or Local sing-box installer flow.
|
||||
|
||||
- `Full install`: local native `sing-box.exe` on `127.0.0.1:1080` plus ProxiFyre/WinPacketFilter.
|
||||
- `ProxiFyre only`: ProxiFyre/WinPacketFilter only, pointed at an existing SOCKS5 proxy such as `127.0.0.1:8080` or `192.168.50.111:8080`.
|
||||
|
||||
The installer keeps profile data under `C:\Tools\vpn-proxy-windows\data`, so rerunning it can replace app files without deleting saved profiles.
|
||||
|
||||
Local UI:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456
|
||||
```
|
||||
|
||||
Recovery commands:
|
||||
|
||||
```powershell
|
||||
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -OpenUi
|
||||
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -Status
|
||||
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -RestartServices
|
||||
```
|
||||
|
||||
The UI manages profiles made of process names, folders, and explicit `.exe` files. It generates ProxiFyre config and restarts ProxiFyre only when the user applies changes.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,6 +91,8 @@ The UI manages profiles made of process names, folders, and explicit `.exe` file
|
||||
▼
|
||||
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||
│
|
||||
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
|
||||
│ └─ FORWARD + MASQUERADE → обычный internet path
|
||||
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
||||
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||
└─ TCP/UDP → TPROXY :7895
|
||||
@@ -159,8 +144,12 @@ ip route replace local 0.0.0.0/0 dev lo table 100
|
||||
|
||||
# Цепочка iptables (порядок правил — критичен)
|
||||
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 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
|
||||
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||
-p tcp → TPROXY :7895 mark 1
|
||||
@@ -171,6 +160,10 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
||||
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||
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
|
||||
|
||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||
@@ -399,9 +392,10 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
||||
| `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_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
|
||||
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` |
|
||||
| `CLIENT_PROXY_PORT` | `8082` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT_START` | `8082` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
|
||||
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
|
||||
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||
@@ -410,6 +404,10 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
|
||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||
| `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) |
|
||||
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||
|
||||
@@ -9,9 +9,9 @@ services:
|
||||
environment:
|
||||
APP_MODE: client
|
||||
PORT: ${PORT:-3456}
|
||||
PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080}
|
||||
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080}
|
||||
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090}
|
||||
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
|
||||
PROXY_BIND_IP: 0.0.0.0
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
@@ -29,13 +29,13 @@ services:
|
||||
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||
ports:
|
||||
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}"
|
||||
volumes:
|
||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||
- sing-box-client-cache:/var/lib/sing-box
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
1752
docs/design/open-design/vpn-proxy-full-mock-prototype.html
Normal file
1752
docs/design/open-design/vpn-proxy-full-mock-prototype.html
Normal file
File diff suppressed because it is too large
Load Diff
774
docs/design/open-design/vpn-proxy-route-console-redesign.html
Normal file
774
docs/design/open-design/vpn-proxy-route-console-redesign.html
Normal file
@@ -0,0 +1,774 @@
|
||||
<!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>
|
||||
@@ -8,7 +8,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | вынесено в `D:\repos\ProxyWarden` |
|
||||
|
||||
## Gateway mode
|
||||
|
||||
@@ -84,15 +84,9 @@
|
||||
|
||||
## Windows gaming mode
|
||||
|
||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
||||
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
|
||||
|
||||
Требования:
|
||||
|
||||
- Native `sing-box.exe`.
|
||||
- Scheduled task или Windows service.
|
||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
||||
- Управление из PowerShell helper.
|
||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
||||
Статус: вынесено в соседний репозиторий `D:\repos\ProxyWarden`. Этот репозиторий больше не содержит Tauri Windows app, Windows-specific планы/evidence или installer scripts.
|
||||
|
||||
## Рабочий порядок
|
||||
|
||||
@@ -102,4 +96,4 @@
|
||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||
5. Добавить gateway docs/install script.
|
||||
6. Потом переносить desktop-proxy.
|
||||
7. Потом приводить Windows mode к новой архитектуре.
|
||||
7. Windows desktop client развивать в `D:\repos\ProxyWarden`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
||||
# 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`.
|
||||
@@ -1,229 +0,0 @@
|
||||
# Windows Client Design
|
||||
|
||||
## 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.
|
||||
|
||||
## Product Shape
|
||||
|
||||
The Windows mode is script-first and UI-assisted. The installer remains the durable entrypoint because Windows driver/service setup needs administrator rights and must stay easy to debug from PowerShell. The web UI is a local control surface on top of the same scripts, not a separate desktop app in the first version.
|
||||
|
||||
The installer supports two paths:
|
||||
|
||||
- **Full install:** install native `sing-box.exe`, configure a local SOCKS/HTTP proxy on `127.0.0.1:1080`, install WinPacketFilter and ProxiFyre, then route selected Windows apps through the local proxy.
|
||||
- **ProxiFyre only:** install WinPacketFilter and ProxiFyre, then point selected Windows apps to an existing proxy target such as `127.0.0.1:8080`, `192.168.50.111:8080`, or another reachable SOCKS5 endpoint.
|
||||
|
||||
The default UI direction is the approved cleaner mockup: one route status, profiles as the main object, selected profile details on the right, and a short recent activity/log section below. The first screen should answer: what is the active proxy target, whether services are running, and which profiles are currently enabled.
|
||||
|
||||
## User Flows
|
||||
|
||||
### Install
|
||||
|
||||
The user opens PowerShell 7 as Administrator and runs:
|
||||
|
||||
```powershell
|
||||
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex
|
||||
```
|
||||
|
||||
The installer checks administrator rights, PowerShell version, architecture, internet access, and required paths. It installs under `C:\Tools\vpn-proxy-windows` and keeps third-party runtime files in focused subdirectories:
|
||||
|
||||
- `C:\Tools\vpn-proxy-windows\app` for this project checkout or archive.
|
||||
- `C:\Tools\vpn-proxy-windows\runtime\node` for portable Node.js when no suitable Node is installed.
|
||||
- `C:\Tools\vpn-proxy-windows\runtime\sing-box` for `sing-box.exe`, config, and logs.
|
||||
- `C:\Tools\ProxiFyre` for ProxiFyre, matching the legacy script path.
|
||||
|
||||
If the user chooses Full install, the installer asks for a subscription or VLESS link, parses it through the existing subscription logic where possible, lets the user select a server, writes the native `sing-box` config, installs a scheduled task for `sing-box`, and starts it.
|
||||
|
||||
If the user chooses ProxiFyre only, the installer asks for a SOCKS5 proxy target and verifies TCP connectivity before writing ProxiFyre config.
|
||||
|
||||
After setup, the installer starts the local control UI on `http://127.0.0.1:3456` and prints recovery commands:
|
||||
|
||||
```powershell
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1"
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1" -OpenUi
|
||||
& "C:\Tools\vpn-proxy-windows\manage.ps1" -Status
|
||||
```
|
||||
|
||||
### Profile Management
|
||||
|
||||
Profiles are the central unit. A profile contains a name, enabled flag, proxy target, protocol list, and app items. Supported item types:
|
||||
|
||||
- `process`: process name such as `Discord`, `Update`, or `Vesktop`.
|
||||
- `folder`: folder path; the system scans `.exe` files and converts them to routable entries.
|
||||
- `exe`: explicit executable file path; the system resolves it to the executable name for ProxiFyre and keeps the full path for display and diagnostics.
|
||||
|
||||
Folder and exe entries are intentionally stored as user-facing source items, while the generated ProxiFyre config is derived. This keeps the UI understandable and makes future ProxiFyre/Proxifier adapter changes possible without changing the profile model.
|
||||
|
||||
When a profile changes, the UI marks it as pending. The user applies changes once. Apply regenerates ProxiFyre `app-config.json`, restarts the ProxiFyre service, then writes an activity entry showing what changed.
|
||||
|
||||
### Runtime Operations
|
||||
|
||||
The UI exposes these actions:
|
||||
|
||||
- start, stop, restart `sing-box` when local mode is installed;
|
||||
- start, stop, restart ProxiFyre;
|
||||
- switch a profile between `local-singbox` and an external proxy target;
|
||||
- add process, folder, or exe entries;
|
||||
- scan folder entries again;
|
||||
- copy diagnostics for support/debugging;
|
||||
- open logs.
|
||||
|
||||
The UI does not auto-change global Windows proxy settings. Routing happens only through ProxiFyre profiles.
|
||||
|
||||
## Architecture
|
||||
|
||||
The active project already has a plain Node API server, React/Vite UI, subscription parser, `sing-box` config generator, logs, traffic parsing, and client/gateway modes. Windows mode should reuse those pieces and add a Windows helper boundary.
|
||||
|
||||
### App Mode
|
||||
|
||||
Add `APP_MODE=windows`. In Windows mode:
|
||||
|
||||
- the HTTP server binds to `127.0.0.1`;
|
||||
- the UI uses Windows labels and hides gateway-only TProxy/device controls;
|
||||
- config generation is proxy-only like client mode, but it targets native `sing-box.exe` rather than Docker;
|
||||
- service and driver actions go through the PowerShell helper, not direct Node assumptions.
|
||||
|
||||
### Windows Helper Boundary
|
||||
|
||||
Create a PowerShell helper module that owns privileged Windows operations:
|
||||
|
||||
- install/update `sing-box.exe`;
|
||||
- install/start/stop scheduled tasks;
|
||||
- install/update WinPacketFilter;
|
||||
- install/update ProxiFyre;
|
||||
- write ProxiFyre config;
|
||||
- query service/task status;
|
||||
- read recent log files;
|
||||
- test proxy connectivity;
|
||||
- manage firewall rules for local proxy ports.
|
||||
|
||||
The Node server calls the helper with explicit command names and JSON input/output. The helper returns structured JSON for every operation:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"action": "proxies.apply",
|
||||
"changed": true,
|
||||
"message": "ProxiFyre config applied and service restarted"
|
||||
}
|
||||
```
|
||||
|
||||
Errors use the same shape with `success: false`, `error`, and optional `details`. The UI never parses raw PowerShell text.
|
||||
|
||||
### Data Files
|
||||
|
||||
Windows mode stores state under `C:\Tools\vpn-proxy-windows\data`:
|
||||
|
||||
- `windows-profiles.json` for profile source data.
|
||||
- `proxy-targets.json` for `local-singbox` and external proxy targets.
|
||||
- `windows-state.json` for last applied profile revision and UI status.
|
||||
- `subscription-cache.json` and `state.json` stay compatible with existing subscription/server selection logic.
|
||||
|
||||
Profile shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "discord-vesktop",
|
||||
"name": "Discord + Vesktop",
|
||||
"enabled": true,
|
||||
"proxyTargetId": "local-singbox",
|
||||
"protocols": ["TCP", "UDP"],
|
||||
"items": [
|
||||
{ "type": "process", "value": "Discord" },
|
||||
{ "type": "process", "value": "Update" },
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "%LOCALAPPDATA%\\vesktop",
|
||||
"recursive": true
|
||||
},
|
||||
{
|
||||
"type": "exe",
|
||||
"value": "C:\\Games\\SomeGame\\game.exe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Generated ProxiFyre config is not edited directly. It is derived from enabled profiles and proxy targets, then written to `C:\Tools\ProxiFyre\app-config.json`.
|
||||
|
||||
### API Surface
|
||||
|
||||
Add Windows-specific endpoints:
|
||||
|
||||
- `GET /api/windows/status`: returns install mode, `sing-box` status, ProxiFyre status, active target, pending changes, and recent activity.
|
||||
- `GET /api/windows/profiles`: returns profile source data with resolved executable counts.
|
||||
- `PUT /api/windows/profiles`: saves profiles without applying.
|
||||
- `POST /api/windows/profiles/apply`: generates ProxiFyre config and restarts service.
|
||||
- `POST /api/windows/profiles/scan`: resolves folder and exe entries for preview.
|
||||
- `GET /api/windows/targets`: returns `local-singbox` and external proxy targets.
|
||||
- `PUT /api/windows/targets`: saves external proxy targets after validation.
|
||||
- `POST /api/windows/service`: start, stop, or restart `sing-box`, ProxiFyre, or the UI service.
|
||||
- `GET /api/windows/logs`: returns recent helper, `sing-box`, and ProxiFyre logs.
|
||||
|
||||
Existing generic endpoints for subscription fetch, server selection, apply, logs, and config validation should be reused where the behavior matches Windows mode.
|
||||
|
||||
## UI Design
|
||||
|
||||
The approved direction is a restrained operational UI:
|
||||
|
||||
- top bar: product name, restart/stop/add profile actions;
|
||||
- left nav: Overview, Profiles, Targets, Logs, Settings;
|
||||
- main status panel: one sentence describing the active route, plus a compact route line such as `Selected apps -> ProxiFyre -> sing-box -> VPN`;
|
||||
- main workspace: profile list on the left, selected profile details on the right;
|
||||
- profile details: target selector, add process/folder/exe input, resolved items list, save/apply controls;
|
||||
- activity panel: recent traffic/service events, not a full noisy log dump.
|
||||
|
||||
Avoid duplicate status blocks. Avoid showing raw implementation concepts like WinPacketFilter unless the user is in diagnostics/settings. The primary terms should be `Profile`, `Proxy target`, `Local sing-box`, `Existing proxy`, `App/folder/exe`, and `Apply changes`.
|
||||
|
||||
## Safety And Constraints
|
||||
|
||||
The UI binds only to `127.0.0.1`. Windows actions that require elevation stay in the installer/helper path. The installer must not delete existing `C:\Tools\vpn-proxy` legacy folders without confirmation.
|
||||
|
||||
Folder and exe routing needs a clear diagnostic note: ProxiFyre routing ultimately depends on what the installed ProxiFyre version accepts. The first implementation should resolve folders and exe paths to executable names for compatibility, while preserving full paths in profile data and diagnostics. If direct path matching is verified in ProxiFyre, the adapter can emit full paths without changing the UI model.
|
||||
|
||||
The installer should be idempotent:
|
||||
|
||||
- re-running it updates project files;
|
||||
- existing subscriptions and profiles are preserved unless the user chooses reset;
|
||||
- existing ProxiFyre config is backed up before overwrite;
|
||||
- failed applies leave the previous generated config available for rollback.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Use focused tests for pure logic:
|
||||
|
||||
- profile normalization;
|
||||
- folder/exe item resolution;
|
||||
- ProxiFyre config generation;
|
||||
- proxy target validation;
|
||||
- Windows helper JSON command contract;
|
||||
- `APP_MODE=windows` public state and config generation.
|
||||
|
||||
Use manual Windows verification for privileged operations:
|
||||
|
||||
- fresh Full install;
|
||||
- fresh ProxiFyre-only install;
|
||||
- re-run installer over existing install;
|
||||
- add process profile;
|
||||
- add folder profile;
|
||||
- add explicit exe profile;
|
||||
- switch a profile from local sing-box to external proxy;
|
||||
- restart ProxiFyre and verify service status;
|
||||
- copy diagnostics after a failed proxy target check.
|
||||
|
||||
Local non-Windows development should still run `npm test` and `npm run build`. Windows-only helper commands should have dry-run or mockable modes so logic can be tested without installing drivers on the development machine.
|
||||
|
||||
## Non-Goals For First Version
|
||||
|
||||
- No Electron or Tauri wrapper.
|
||||
- No global Windows system proxy changes.
|
||||
- No transparent routing without ProxiFyre.
|
||||
- No remote multi-device management.
|
||||
- No automatic uninstall of unrelated WinPacketFilter users.
|
||||
- No Proxifier support until ProxiFyre behavior is stable.
|
||||
|
||||
## Implementation Defaults
|
||||
|
||||
- The UI server runs when opened by `manage.ps1 -OpenUi` in the first version. An at-logon scheduled UI task can be added later after the helper and UI are stable.
|
||||
- Full install uses portable Node/npm when the machine has no suitable Node.js. The installer builds the React UI locally for MVP; a prebuilt release artifact can replace that later without changing user-facing behavior.
|
||||
- ProxiFyre generation emits process names in the first version for compatibility. Full folder and exe paths remain in profile data and diagnostics; the adapter can start emitting full paths later if ProxiFyre path matching is verified.
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-3456}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||
|
||||
@@ -5,11 +5,15 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
|
||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||
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_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||
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}"
|
||||
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}"
|
||||
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
||||
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
||||
@@ -36,13 +40,32 @@ cleanup_proxy_firewall() {
|
||||
cleanup_tproxy() {
|
||||
log "cleanup tproxy rules"
|
||||
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 -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 route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||
# 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() {
|
||||
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
|
||||
export DIRECT_BYPASS_CACHE
|
||||
@@ -76,14 +99,32 @@ setup_proxy_firewall() {
|
||||
setup_tproxy() {
|
||||
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
||||
cleanup_tproxy
|
||||
enable_ip_forwarding
|
||||
|
||||
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"
|
||||
|
||||
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)
|
||||
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" -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
|
||||
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VPN Proxy Gateway</title>
|
||||
<title>VPN</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -47,6 +47,10 @@ TPROXY_PORT=7895
|
||||
TPROXY_MARK=1
|
||||
TPROXY_TABLE=100
|
||||
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
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
|
||||
@@ -5,7 +5,7 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||
COMPOSE_FILE="docker-compose.client.yml"
|
||||
DEFAULT_PROXY_PORT="8080"
|
||||
DEFAULT_PROXY_PORT="8082"
|
||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||
@@ -59,15 +59,6 @@ ask_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() {
|
||||
local port="$1"
|
||||
local line
|
||||
@@ -82,18 +73,7 @@ published_port_conflicts() {
|
||||
}
|
||||
|
||||
proxy_port_conflicts() {
|
||||
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
|
||||
published_port_conflicts "$1"
|
||||
}
|
||||
|
||||
assert_proxy_port_available() {
|
||||
@@ -105,8 +85,8 @@ assert_proxy_port_available() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \
|
||||
"$port" "$(port_range_end "$port")" "$conflicts" >&2
|
||||
printf '[vpn-proxy-client] proxy port %s is already used:\n%s\n' \
|
||||
"$port" "$conflicts" >&2
|
||||
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||
}
|
||||
|
||||
@@ -170,8 +150,8 @@ choose_ui_port() {
|
||||
}
|
||||
|
||||
assert_ui_outside_proxy_range() {
|
||||
if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then
|
||||
die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}"
|
||||
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
|
||||
die "UI port ${UI_PORT} overlaps proxy port"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -181,7 +161,7 @@ wait_for_client_ui() {
|
||||
local attempt
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -fsS "$ui_url" >/dev/null 2>&1; then
|
||||
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
@@ -254,7 +234,7 @@ fi
|
||||
|
||||
PROXY_PORT="$(ask_proxy_port)"
|
||||
assert_proxy_port_available "$PROXY_PORT"
|
||||
PROXY_PORT_END="$(port_range_end "$PROXY_PORT")"
|
||||
PROXY_PORT_END="$PROXY_PORT"
|
||||
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||
UI_PORT="${UI_PORT:-3456}"
|
||||
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||
@@ -268,7 +248,7 @@ set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
|
||||
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||
|
||||
log "UI port: http://127.0.0.1:${UI_PORT}"
|
||||
log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})"
|
||||
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||
|
||||
log "building and starting Docker client"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
@@ -283,7 +263,7 @@ UI:
|
||||
|
||||
Proxy:
|
||||
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
|
||||
UI can switch proxy port within the Docker-published ${PROXY_PORT}-${PROXY_PORT_END} range.
|
||||
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
|
||||
|
||||
Useful commands:
|
||||
cd ~/.vpn-proxy-client
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
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"
|
||||
@@ -1,154 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
param(
|
||||
[switch]$OpenUi,
|
||||
[switch]$Status,
|
||||
[switch]$RestartServices
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AppDir = Split-Path -Parent (Split-Path -Parent $ScriptDir)
|
||||
if (-not (Test-Path (Join-Path $AppDir "package.json"))) {
|
||||
throw "Cannot locate app root from $ScriptDir"
|
||||
}
|
||||
|
||||
$Root = $env:VPN_PROXY_WINDOWS_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($Root)) {
|
||||
if ((Split-Path -Leaf $AppDir) -eq "app") {
|
||||
$Root = Split-Path -Parent $AppDir
|
||||
} else {
|
||||
$Root = $AppDir
|
||||
}
|
||||
}
|
||||
|
||||
$env:VPN_PROXY_WINDOWS_ROOT = $Root
|
||||
$env:APP_MODE = "windows"
|
||||
$env:DATA_DIR = Join-Path $Root "data"
|
||||
$env:DIST_DIR = Join-Path $AppDir "dist"
|
||||
$env:PROXY_PORT = "1080"
|
||||
$env:PROXY_BIND_IP = "127.0.0.1"
|
||||
$env:SING_BOX_CONFIG = Join-Path $Root "runtime\sing-box\config.json"
|
||||
$env:SING_BOX_CACHE = Join-Path $Root "runtime\sing-box\cache.db"
|
||||
$env:WINDOWS_HELPER = Join-Path $AppDir "scripts\windows\helper.ps1"
|
||||
$env:PATH = "$(Join-Path $Root "runtime\sing-box");$env:PATH"
|
||||
|
||||
function Get-NodeCommand {
|
||||
$portable = Join-Path $Root "runtime\node\node.exe"
|
||||
if (Test-Path $portable) { return $portable }
|
||||
return "node"
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
$inputJson = @{ action = "status.get"; payload = @{} } | ConvertTo-Json -Compress
|
||||
$inputJson | & $env:WINDOWS_HELPER
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($RestartServices) {
|
||||
$helper = $env:WINDOWS_HELPER
|
||||
(@{ action = "service.control"; payload = @{ service = "proxifyre"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
|
||||
(@{ action = "service.control"; payload = @{ service = "sing-box"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($OpenUi) {
|
||||
$node = Get-NodeCommand
|
||||
Start-Process "http://127.0.0.1:3456"
|
||||
& $node (Join-Path $AppDir "src\server\index.js")
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "VPN Proxy Windows"
|
||||
Write-Host " -OpenUi Start local UI"
|
||||
Write-Host " -Status Print JSON status"
|
||||
Write-Host " -RestartServices Restart ProxiFyre and sing-box"
|
||||
@@ -1,18 +1,37 @@
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
const rawAppMode = String(process.env.APP_MODE || "gateway").toLowerCase();
|
||||
const appMode = ["gateway", "client", "windows"].includes(rawAppMode)
|
||||
? rawAppMode
|
||||
: "gateway";
|
||||
const parsePort = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : fallback;
|
||||
};
|
||||
const proxyPort = parsePort(
|
||||
process.env.PROXY_PORT,
|
||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||
);
|
||||
const clientProxyPortStart = parsePort(
|
||||
process.env.CLIENT_PROXY_PORT_START,
|
||||
proxyPort,
|
||||
);
|
||||
const clientProxyPortEnd = parsePort(
|
||||
process.env.CLIENT_PROXY_PORT_END,
|
||||
clientProxyPortStart,
|
||||
);
|
||||
|
||||
export const settings = {
|
||||
appMode,
|
||||
port: Number(process.env.PORT || 3456),
|
||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
|
||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
port: parsePort(process.env.PORT, 3456),
|
||||
proxyPort,
|
||||
clientProxyPortStart,
|
||||
clientProxyPortEnd,
|
||||
tproxyPort: parsePort(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",
|
||||
dataDir,
|
||||
distDir: process.env.DIST_DIR || "/app/dist",
|
||||
@@ -26,24 +45,10 @@ export const settings = {
|
||||
devicesPath: path.join(dataDir, "devices.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
windowsProfilesPath: path.join(dataDir, "windows-profiles.json"),
|
||||
windowsTargetsPath: path.join(dataDir, "proxy-targets.json"),
|
||||
windowsStatePath: path.join(dataDir, "windows-state.json"),
|
||||
windowsActivityPath: path.join(dataDir, "windows-activity.json"),
|
||||
windowsHelperPath:
|
||||
process.env.WINDOWS_HELPER || path.resolve("scripts/windows/helper.ps1"),
|
||||
proxifyreConfigPath:
|
||||
process.env.PROXIFYRE_CONFIG ||
|
||||
"C:\\Tools\\ProxiFyre\\app-config.json",
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName:
|
||||
appMode === "windows"
|
||||
? "VPN Proxy Windows"
|
||||
: appMode === "client"
|
||||
? "VPN Proxy Client"
|
||||
: "VPN Proxy Gateway",
|
||||
appName: "VPN Proxy Gateway",
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
|
||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
|
||||
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||
export const DEFAULT_PROXY_MODE = "vpn";
|
||||
@@ -27,7 +27,6 @@ function writeJson(filePath, value) {
|
||||
|
||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
if (value === "bypass") return "direct";
|
||||
return DEVICE_MODES.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,12 @@ import {
|
||||
buildSharedProxyInfo,
|
||||
checkSharedProxyGateway,
|
||||
} from "./sharedProxy.js";
|
||||
import {
|
||||
sourceBypassCidrs,
|
||||
syncTproxySourceBypass,
|
||||
} from "./tproxySourceBypass.js";
|
||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||
import { tcpPing, resolveHost } from "./ping.js";
|
||||
import {
|
||||
buildProxiFyreConfig,
|
||||
createActivityEntry,
|
||||
normalizeProxyTargets,
|
||||
normalizeWindowsProfiles,
|
||||
summarizeProfiles,
|
||||
} from "./windowsProfiles.js";
|
||||
import { windowsHelper } from "./windowsHelper.js";
|
||||
|
||||
const APPLY_HISTORY_LIMIT = 10;
|
||||
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
||||
@@ -611,9 +607,7 @@ function publicState() {
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const clientSettings = readClientSettings();
|
||||
const windowsTargets =
|
||||
settings.appMode === "windows" ? readProxyTargets() : [];
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
const { subscriptionUrl, servers = [], ...rest } = state;
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
port: settings.port,
|
||||
@@ -644,18 +638,12 @@ function publicState() {
|
||||
directBypassCount,
|
||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||
directBypassAvailable: IPSET_AVAILABLE,
|
||||
windows:
|
||||
settings.appMode === "windows"
|
||||
? {
|
||||
profiles: summarizeProfiles(
|
||||
readWindowsProfiles(),
|
||||
windowsTargets,
|
||||
),
|
||||
targets: windowsTargets,
|
||||
activity: readWindowsActivity().slice(-20).reverse(),
|
||||
}
|
||||
: null,
|
||||
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
|
||||
...rest,
|
||||
servers: servers.map((server) => ({
|
||||
...server,
|
||||
tag: String(server.tag || '').trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -707,62 +695,6 @@ function normalizeDeviceRules(input) {
|
||||
}));
|
||||
}
|
||||
|
||||
function readWindowsProfiles() {
|
||||
return normalizeWindowsProfiles(readJson(settings.windowsProfilesPath, []));
|
||||
}
|
||||
|
||||
function writeWindowsProfiles(profiles) {
|
||||
const normalized = normalizeWindowsProfiles(profiles);
|
||||
writeJson(settings.windowsProfilesPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readProxyTargets() {
|
||||
return normalizeProxyTargets(readJson(settings.windowsTargetsPath, []));
|
||||
}
|
||||
|
||||
function writeProxyTargets(targets) {
|
||||
const normalized = normalizeProxyTargets(targets);
|
||||
writeJson(
|
||||
settings.windowsTargetsPath,
|
||||
normalized.filter((target) => !target.managed),
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readWindowsActivity() {
|
||||
return readJson(settings.windowsActivityPath, []).slice(-100);
|
||||
}
|
||||
|
||||
function pushWindowsActivity(type, message, details = {}) {
|
||||
const activity = readWindowsActivity();
|
||||
const entry = createActivityEntry(type, message, details);
|
||||
writeJson(settings.windowsActivityPath, [...activity, entry].slice(-100));
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function getWindowsStatus() {
|
||||
let helperStatus = null;
|
||||
if (settings.appMode === "windows") {
|
||||
try {
|
||||
helperStatus = await windowsHelper.run("status.get", {});
|
||||
} catch (error) {
|
||||
helperStatus = { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
installMode:
|
||||
readJson(settings.windowsStatePath, {}).installMode || "not-configured",
|
||||
profiles: summarizeProfiles(profiles, targets),
|
||||
targets,
|
||||
activity: readWindowsActivity().slice(-20).reverse(),
|
||||
helperStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) {
|
||||
@@ -886,99 +818,6 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, { success: true, config });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/status") {
|
||||
return sendJson(res, 200, { success: true, ...(await getWindowsStatus()) });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/profiles") {
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
profiles,
|
||||
summaries: summarizeProfiles(profiles, targets),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && req.url === "/api/windows/profiles") {
|
||||
const body = await readBody(req);
|
||||
const profiles = writeWindowsProfiles(body.profiles || []);
|
||||
pushWindowsActivity("profiles.saved", "Profiles saved", {
|
||||
count: profiles.length,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
profiles,
|
||||
summaries: summarizeProfiles(profiles, readProxyTargets()),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/profiles/scan") {
|
||||
const body = await readBody(req);
|
||||
const profiles = normalizeWindowsProfiles(body.profiles || []);
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
summaries: summarizeProfiles(profiles, readProxyTargets()),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/profiles/apply") {
|
||||
const profiles = readWindowsProfiles();
|
||||
const targets = readProxyTargets();
|
||||
const proxifyreConfig = buildProxiFyreConfig(profiles, targets);
|
||||
const helperResult = await windowsHelper.run("proxifyre.apply", {
|
||||
configPath: settings.proxifyreConfigPath,
|
||||
config: proxifyreConfig,
|
||||
});
|
||||
pushWindowsActivity("profiles.applied", "ProxiFyre config applied", {
|
||||
proxyGroups: proxifyreConfig.proxies.length,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
config: proxifyreConfig,
|
||||
helperResult,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/targets") {
|
||||
return sendJson(res, 200, { success: true, targets: readProxyTargets() });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && req.url === "/api/windows/targets") {
|
||||
const body = await readBody(req);
|
||||
const targets = writeProxyTargets(body.targets || []);
|
||||
pushWindowsActivity("targets.saved", "Proxy targets saved", {
|
||||
count: targets.length,
|
||||
});
|
||||
return sendJson(res, 200, { success: true, targets });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/windows/service") {
|
||||
const body = await readBody(req);
|
||||
const service = String(body.service || "");
|
||||
const action = String(body.action || "");
|
||||
if (!["sing-box", "proxifyre", "ui"].includes(service)) {
|
||||
return sendJson(res, 400, { success: false, error: "Unknown service" });
|
||||
}
|
||||
if (!["start", "stop", "restart"].includes(action)) {
|
||||
return sendJson(res, 400, { success: false, error: "Unknown action" });
|
||||
}
|
||||
const helperResult = await windowsHelper.run("service.control", {
|
||||
service,
|
||||
action,
|
||||
});
|
||||
pushWindowsActivity("service.control", `${service} ${action}`, {
|
||||
service,
|
||||
action,
|
||||
});
|
||||
return sendJson(res, 200, { success: true, helperResult });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/windows/logs") {
|
||||
const helperResult = await windowsHelper.run("logs.get", {});
|
||||
return sendJson(res, 200, { success: true, helperResult });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/logs") {
|
||||
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
|
||||
}
|
||||
@@ -1140,6 +979,13 @@ async function handleApi(req, res) {
|
||||
devices: body.devices,
|
||||
};
|
||||
const profiles = writeDeviceProfiles(input);
|
||||
const sourceBypassResult = syncTproxySourceBypass(profiles);
|
||||
if (!sourceBypassResult.success) {
|
||||
pushLog(
|
||||
"warning",
|
||||
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
|
||||
);
|
||||
}
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
const devicesUpdatedAt = new Date().toISOString();
|
||||
writeJson(settings.statePath, {
|
||||
@@ -1149,6 +995,8 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
...profiles,
|
||||
sourceBypassCidrs: sourceBypassCidrs(profiles),
|
||||
sourceBypassResult,
|
||||
devicesUpdatedAt,
|
||||
});
|
||||
}
|
||||
@@ -1480,7 +1328,7 @@ async function handleApi(req, res) {
|
||||
servers.map(async (server) => {
|
||||
const ping = await tcpPing(server.server, server.server_port, 3000);
|
||||
return {
|
||||
tag: server.tag,
|
||||
tag: String(server.tag || '').trim(),
|
||||
...ping,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -1711,6 +1559,14 @@ process.on("SIGINT", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
|
||||
if (!sourceBypassStartup.success) {
|
||||
pushLog(
|
||||
"warning",
|
||||
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// При старте пробуем подхватить уже запущенный sing-box
|
||||
const existingPid = readSingboxPid();
|
||||
if (existingPid && isPidAlive(existingPid)) {
|
||||
|
||||
@@ -159,6 +159,21 @@ export function matchRoute(target, customRules, options = {}) {
|
||||
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
|
||||
if (target.ip && isPrivateIp(target.ip)) {
|
||||
return {
|
||||
|
||||
@@ -267,8 +267,6 @@ export function buildGatewayConfig(
|
||||
{ bypassAll = false } = {},
|
||||
) {
|
||||
const customRuleSets = readCustomRuleSets();
|
||||
const proxyOnlyMode =
|
||||
settings.appMode === "client" || settings.appMode === "windows";
|
||||
const clientMode = settings.appMode === "client";
|
||||
const clientSettings = clientMode ? readClientSettings() : null;
|
||||
const sharedOutbound =
|
||||
@@ -297,7 +295,7 @@ export function buildGatewayConfig(
|
||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||
const inbounds = [
|
||||
...(proxyOnlyMode
|
||||
...(clientMode
|
||||
? []
|
||||
: [
|
||||
{
|
||||
@@ -340,19 +338,16 @@ export function buildGatewayConfig(
|
||||
{ type: "block", tag: "block" },
|
||||
],
|
||||
route: {
|
||||
rule_set:
|
||||
bypassAll || proxyOnlyMode
|
||||
? []
|
||||
: ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rules: bypassAll
|
||||
? [{ ip_is_private: true, outbound: "direct" }]
|
||||
: proxyOnlyMode
|
||||
: clientMode
|
||||
? proxyOnlyRules
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||
includeTransparent: !proxyOnlyMode,
|
||||
includeTransparent: !clientMode,
|
||||
}),
|
||||
final: "direct",
|
||||
auto_detect_interface: true,
|
||||
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function parseSubscriptionBody(body) {
|
||||
const servers = outbounds
|
||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||
.map((outbound) => ({
|
||||
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
||||
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||
type: outbound.type,
|
||||
server: outbound.server || 'unknown',
|
||||
server_port: outbound.server_port || 443,
|
||||
|
||||
124
src/server/tproxySourceBypass.js
Normal file
124
src/server/tproxySourceBypass.js
Normal file
@@ -0,0 +1,124 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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();
|
||||
@@ -1,210 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ITEM_TYPES = new Set(["process", "folder", "exe"]);
|
||||
const PROTOCOLS = new Set(["TCP", "UDP"]);
|
||||
|
||||
function slug(value, fallback) {
|
||||
const cleaned = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function cleanString(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function processName(value) {
|
||||
const base = cleanString(value).split(/[\\/]/).pop() || "";
|
||||
return base.replace(/\.exe$/i, "").trim();
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function normalizeWindowsProfiles(input) {
|
||||
return (Array.isArray(input) ? input : [])
|
||||
.map((profile, index) => {
|
||||
const name = cleanString(profile.name) || `Profile ${index + 1}`;
|
||||
const items = (Array.isArray(profile.items) ? profile.items : [])
|
||||
.filter((item) => ITEM_TYPES.has(item?.type))
|
||||
.map((item) => ({
|
||||
type: item.type,
|
||||
value:
|
||||
item.type === "process"
|
||||
? processName(item.value)
|
||||
: cleanString(item.value),
|
||||
recursive: item.type === "folder" ? item.recursive !== false : false,
|
||||
}))
|
||||
.filter((item) => item.value);
|
||||
|
||||
return {
|
||||
id: slug(profile.id || name, `profile-${index + 1}`),
|
||||
name,
|
||||
enabled: profile.enabled !== false,
|
||||
proxyTargetId: cleanString(profile.proxyTargetId) || "local-singbox",
|
||||
protocols: unique(
|
||||
(Array.isArray(profile.protocols)
|
||||
? profile.protocols
|
||||
: ["TCP", "UDP"])
|
||||
.map((protocol) => cleanString(protocol).toUpperCase())
|
||||
.filter((protocol) => PROTOCOLS.has(protocol)),
|
||||
),
|
||||
items,
|
||||
};
|
||||
})
|
||||
.map((profile) => ({
|
||||
...profile,
|
||||
protocols: profile.protocols.length ? profile.protocols : ["TCP", "UDP"],
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeProxyTargets(input) {
|
||||
const local = {
|
||||
id: "local-singbox",
|
||||
name: "Local sing-box",
|
||||
protocol: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 1080,
|
||||
managed: true,
|
||||
};
|
||||
const seen = new Set([local.id]);
|
||||
const custom = (Array.isArray(input) ? input : [])
|
||||
.map((target, index) => ({
|
||||
id: slug(target.id || target.name, `target-${index + 1}`),
|
||||
name: cleanString(target.name) || `Proxy target ${index + 1}`,
|
||||
protocol:
|
||||
cleanString(target.protocol || "socks5").toLowerCase() === "http"
|
||||
? "http"
|
||||
: "socks5",
|
||||
host: cleanString(target.host),
|
||||
port: Number.parseInt(target.port, 10),
|
||||
managed: false,
|
||||
}))
|
||||
.filter((target) => {
|
||||
if (!target.host || !Number.isInteger(target.port)) return false;
|
||||
if (target.port <= 0 || target.port > 65535) return false;
|
||||
if (seen.has(target.id)) return false;
|
||||
seen.add(target.id);
|
||||
return true;
|
||||
});
|
||||
return [local, ...custom];
|
||||
}
|
||||
|
||||
function joinPath(base, name, pathSep) {
|
||||
return base.endsWith(pathSep) ? `${base}${name}` : `${base}${pathSep}${name}`;
|
||||
}
|
||||
|
||||
function walkExeFiles(dir, { fsAdapter, recursive, pathSep }) {
|
||||
const entries = fsAdapter.readdirSync(dir, { withFileTypes: true });
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinPath(dir, entry.name, pathSep);
|
||||
if (entry.isFile() && /\.exe$/i.test(entry.name)) results.push(fullPath);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
results.push(...walkExeFiles(fullPath, { fsAdapter, recursive, pathSep }));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function resolveProfileItems(items, options = {}) {
|
||||
const fsAdapter = options.fsAdapter || fs;
|
||||
const pathSep = options.pathSep || path.sep;
|
||||
const resolved = [];
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
if (item.type === "process") {
|
||||
const appName = processName(item.value);
|
||||
if (appName) resolved.push({ ...item, appName, source: item.value });
|
||||
}
|
||||
if (item.type === "exe") {
|
||||
const appName = processName(item.value);
|
||||
if (appName) resolved.push({ ...item, appName, source: item.value });
|
||||
}
|
||||
if (item.type === "folder" && fsAdapter.existsSync(item.value)) {
|
||||
const stat = fsAdapter.statSync(item.value);
|
||||
if (stat.isDirectory()) {
|
||||
for (const filePath of walkExeFiles(item.value, {
|
||||
fsAdapter,
|
||||
recursive: item.recursive !== false,
|
||||
pathSep,
|
||||
})) {
|
||||
resolved.push({
|
||||
...item,
|
||||
appName: processName(filePath),
|
||||
source: filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const byName = new Map();
|
||||
for (const item of resolved) {
|
||||
if (!byName.has(item.appName)) byName.set(item.appName, item);
|
||||
}
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
export function buildProxiFyreConfig(profiles, targets, options = {}) {
|
||||
const normalizedTargets = normalizeProxyTargets(targets);
|
||||
const targetById = new Map(
|
||||
normalizedTargets.map((target) => [target.id, target]),
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const profile of normalizeWindowsProfiles(profiles).filter(
|
||||
(item) => item.enabled,
|
||||
)) {
|
||||
const target =
|
||||
targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
|
||||
const resolved = resolveProfileItems(profile.items, options);
|
||||
if (!target || resolved.length === 0) continue;
|
||||
const key = `${target.id}|${profile.protocols.join(",")}`;
|
||||
const existing = groups.get(key) || {
|
||||
appNames: [],
|
||||
socks5ProxyEndpoint: `${target.host}:${target.port}`,
|
||||
supportedProtocols: profile.protocols,
|
||||
};
|
||||
existing.appNames.push(...resolved.map((item) => item.appName));
|
||||
existing.appNames = unique(existing.appNames).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
return {
|
||||
logLevel: "Info",
|
||||
proxies: Array.from(groups.values()),
|
||||
excludes: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeProfiles(profiles, targets, options = {}) {
|
||||
const normalizedTargets = normalizeProxyTargets(targets);
|
||||
const targetById = new Map(
|
||||
normalizedTargets.map((target) => [target.id, target]),
|
||||
);
|
||||
return normalizeWindowsProfiles(profiles).map((profile) => {
|
||||
const resolvedItems = resolveProfileItems(profile.items, options);
|
||||
const target =
|
||||
targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
|
||||
return {
|
||||
...profile,
|
||||
target,
|
||||
resolvedCount: resolvedItems.length,
|
||||
resolvedItems,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createActivityEntry(type, message, details = {}) {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
ts: new Date().toISOString(),
|
||||
type,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { StatusPane } from './components/StatusPane.jsx';
|
||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
|
||||
import { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
@@ -34,10 +33,6 @@ function App() {
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: [],
|
||||
});
|
||||
const [clientSettings, setClientSettings] = useState({
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -83,7 +78,6 @@ function App() {
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
@@ -98,9 +92,6 @@ function App() {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
if (state?.mode === 'windows' && (page === 'servers' || page === 'routing')) {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
@@ -113,7 +104,9 @@ function App() {
|
||||
setError('');
|
||||
try {
|
||||
const result = await fn();
|
||||
if (!quiet && label) pushToast({ kind: 'success', title: label });
|
||||
if (!quiet && label && state?.mode !== 'client') {
|
||||
pushToast({ kind: 'success', title: label });
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -129,9 +122,12 @@ function App() {
|
||||
return withBusy('Подписка обновлена', async () => {
|
||||
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
||||
setServers(data.servers || []);
|
||||
if (!selectedTag && data.servers?.length) {
|
||||
setSelectedTag(data.servers[0].tag);
|
||||
setPendingTag(data.servers[0].tag);
|
||||
if (data.servers?.length) {
|
||||
const nextTag = data.servers.some((server) => server.tag === selectedTag)
|
||||
? selectedTag
|
||||
: data.servers[0].tag;
|
||||
setSelectedTag(nextTag);
|
||||
setPendingTag(nextTag);
|
||||
}
|
||||
await loadState();
|
||||
});
|
||||
@@ -162,7 +158,7 @@ function App() {
|
||||
});
|
||||
setApplyStatus('idle');
|
||||
|
||||
if (previous && previous !== target) {
|
||||
if (state?.mode !== 'client' && previous && previous !== target) {
|
||||
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
|
||||
@@ -184,8 +180,8 @@ function App() {
|
||||
}
|
||||
|
||||
// === sing-box control ===
|
||||
async function stopSingbox() {
|
||||
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||
async function stopSingbox(confirmFirst = true) {
|
||||
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
||||
}
|
||||
async function restartSingbox() {
|
||||
@@ -228,7 +224,19 @@ function App() {
|
||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
|
||||
setState((prev) => 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) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
@@ -270,22 +278,6 @@ function App() {
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
async function saveClientSettings(nextSettings) {
|
||||
return withBusy(null, async () => {
|
||||
const data = await api.clientSettings.save(nextSettings);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
}, { quiet: true });
|
||||
}
|
||||
|
||||
async function checkSharedProxy(url) {
|
||||
return withBusy('Общий proxy подключён', async () => {
|
||||
const data = await api.clientSettings.checkSharedProxy(url);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Rules CRUD ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
@@ -385,7 +377,6 @@ function App() {
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
const isWindowsMode = state?.mode === 'windows';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
@@ -403,8 +394,11 @@ function App() {
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
if (!state) return <div className="app-loading">VPN</div>;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className={`app${isClientMode ? ' client-app' : ''}`}>
|
||||
{!isClientMode && (
|
||||
<Topbar
|
||||
state={state}
|
||||
status={status}
|
||||
@@ -413,15 +407,13 @@ function App() {
|
||||
onRestart={restartSingbox}
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}${isWindowsMode ? ' windows-mode' : ''}`}>
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<main className="app-main">
|
||||
{page === 'overview' && isWindowsMode && (
|
||||
<WindowsOverviewPage pushToast={pushToast} />
|
||||
)}
|
||||
{(page === 'overview' || isClientMode) && !isWindowsMode && (
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
@@ -433,13 +425,10 @@ function App() {
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
clientSettings={clientSettings}
|
||||
onSaveClientSettings={saveClientSettings}
|
||||
onCheckSharedProxy={checkSharedProxy}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onStop={() => stopSingbox(false)}
|
||||
/>
|
||||
) : (
|
||||
<OverviewPage
|
||||
@@ -455,7 +444,7 @@ function App() {
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && !isClientMode && !isWindowsMode && (
|
||||
{page === 'servers' && !isClientMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
@@ -471,7 +460,7 @@ function App() {
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && !isClientMode && !isWindowsMode && (
|
||||
{page === 'routing' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
@@ -505,7 +494,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && dirtyRouting && !isWindowsMode) && (
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
@@ -530,7 +519,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(page === 'servers' && dirtyServer && !isWindowsMode) && (
|
||||
{(page === 'servers' && dirtyServer) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className="dot warning" />
|
||||
@@ -547,7 +536,7 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isClientMode && !isWindowsMode && (
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
state={state}
|
||||
busy={busy}
|
||||
|
||||
@@ -44,20 +44,6 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
clientSettings: {
|
||||
get: () => request("/api/client-settings"),
|
||||
save: (clientSettings) =>
|
||||
request("/api/client-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ clientSettings }),
|
||||
}),
|
||||
checkSharedProxy: (url) =>
|
||||
request("/api/client-settings/shared-proxy/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
get: () => request("/api/rule-sets"),
|
||||
save: (ruleSets) =>
|
||||
@@ -123,40 +109,5 @@ 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" }),
|
||||
};
|
||||
|
||||
@@ -1,364 +1,235 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
import { resolveClientRoute } from '../utils/clientRoute.js';
|
||||
|
||||
function CopyValue({ value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="client-copy" type="button" onClick={copy}>
|
||||
<span>{value}</span>
|
||||
<strong>{copied ? 'OK' : 'Copy'}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPanel({ route, state }) {
|
||||
const statusLabel = {
|
||||
connected: 'Работает',
|
||||
stopped: 'Остановлен',
|
||||
empty: 'Не настроен',
|
||||
}[route.status];
|
||||
|
||||
return (
|
||||
<section className={`client-status-panel ${route.status}`}>
|
||||
<div className="client-status-main">
|
||||
<span className={`client-status-dot ${route.status}`} />
|
||||
<div>
|
||||
<div className="client-eyebrow">Текущий маршрут</div>
|
||||
<h1>{route.title}</h1>
|
||||
<p>{route.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-status-facts">
|
||||
<div>
|
||||
<small>Куда</small>
|
||||
<strong>{route.target}</strong>
|
||||
<span>{route.targetDetail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Локальный proxy</small>
|
||||
<strong>{route.localProxy}</strong>
|
||||
<span>HTTP и SOCKS5</span>
|
||||
</div>
|
||||
<div>
|
||||
<small>Сервис</small>
|
||||
<strong>{statusLabel}</strong>
|
||||
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteLine({ route }) {
|
||||
return (
|
||||
<div className="client-route-line">
|
||||
{route.path.map((item, index) => (
|
||||
<React.Fragment key={`${item}-${index}`}>
|
||||
<span>{item}</span>
|
||||
{index < route.path.length - 1 && <b>→</b>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ active, selected, title, subtitle, onClick, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`client-mode-button ${selected ? 'selected' : ''} ${active ? 'active' : ''}`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
<span>{subtitle}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewaySettings({ settings, busy, onCheck }) {
|
||||
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
|
||||
const sharedProxy = settings?.sharedProxy;
|
||||
|
||||
useEffect(() => {
|
||||
setDraftUrl(settings?.sharedProxyControlUrl || '');
|
||||
}, [settings?.sharedProxyControlUrl]);
|
||||
|
||||
return (
|
||||
<div className="client-mode-settings">
|
||||
<div className="field">
|
||||
<label className="field-label">Адрес gateway UI</label>
|
||||
<div className="client-inline-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="http://192.168.50.111:3456"
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sharedProxy && (
|
||||
<div className="client-current-target">
|
||||
<small>Найден общий proxy</small>
|
||||
<strong>{sharedProxy.host}:{sharedProxy.port}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VpnSettings({
|
||||
state,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const activeServer = servers.find((server) => server.tag === selected);
|
||||
|
||||
return (
|
||||
<div className="client-mode-settings">
|
||||
<div className="field">
|
||||
<label className="field-label">Подписка или VLESS</label>
|
||||
<div className="client-inline-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://… или vless://…"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
|
||||
/>
|
||||
<button className="btn btn-secondary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="client-inline-form">
|
||||
<select
|
||||
className="select"
|
||||
value={selected}
|
||||
disabled={!servers.length}
|
||||
onChange={(e) => setPendingTag(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.tag} value={server.tag}>
|
||||
{flagFor(server)} {server.tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectSettings({ busy, onEnable }) {
|
||||
return (
|
||||
<div className="client-mode-settings direct">
|
||||
<div>
|
||||
<strong>Прямой режим</strong>
|
||||
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
|
||||
Включить напрямую
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxySettings({ state, settings, busy, onSave }) {
|
||||
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
|
||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||
const [draftPort, setDraftPort] = useState(String(port));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftPort(String(port));
|
||||
}, [port]);
|
||||
|
||||
const parsed = Number.parseInt(draftPort, 10);
|
||||
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
|
||||
const dirty = !invalid && parsed !== port;
|
||||
|
||||
return (
|
||||
<aside className="client-side-panel">
|
||||
<div>
|
||||
<div className="client-panel-title">Адрес для приложений</div>
|
||||
<div className="client-copy-stack">
|
||||
<CopyValue value={`http://127.0.0.1:${port}`} />
|
||||
<CopyValue value={`socks5://127.0.0.1:${port}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Порт proxy</label>
|
||||
<div className="client-port-row">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={range.start}
|
||||
max={range.end}
|
||||
value={draftPort}
|
||||
onChange={(e) => setDraftPort(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => onSave({ ...settings, proxyPort: parsed })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<small className={invalid ? 'field-error' : 'field-hint'}>{range.start}–{range.end}</small>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
connectionAction,
|
||||
formatConnectionDuration,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
} from '../utils/clientControls.js';
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
clientSettings,
|
||||
onSaveClientSettings,
|
||||
onCheckSharedProxy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) {
|
||||
const route = useMemo(
|
||||
() => resolveClientRoute({ state, activeServer }),
|
||||
[state, activeServer],
|
||||
);
|
||||
const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode);
|
||||
const connected = Boolean(state?.singboxRunning);
|
||||
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||
const canStart = Boolean(selectedTag || state?.configExists);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [pings, setPings] = useState({});
|
||||
const [copiedProxy, setCopiedProxy] = useState('');
|
||||
const subscriptionInputRef = useRef(null);
|
||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.mode !== 'none') setSetupMode(route.mode);
|
||||
}, [route.mode]);
|
||||
setNow(Date.now());
|
||||
if (!connected || !state?.singboxStartedAt) return undefined;
|
||||
|
||||
function enableDirect() {
|
||||
return onSaveClientSettings({
|
||||
...clientSettings,
|
||||
homeBypassEnabled: true,
|
||||
sharedProxyEnabled: false,
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [connected, state?.singboxStartedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!servers.length) {
|
||||
setPings({});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
|
||||
api.servers.pingAll()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPings(Object.fromEntries((data.results || []).map((ping) => [
|
||||
String(ping.tag || '').trim(),
|
||||
ping,
|
||||
])));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [serverKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||
}, [editingSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||||
const timer = setTimeout(() => setEditingSubscription(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||
|
||||
async function toggleConnection() {
|
||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||
if (action?.type === 'stop') return onStop();
|
||||
if (action?.type === 'apply') return onApply(action.selectedTag);
|
||||
if (action?.type === 'restart') return onRestart();
|
||||
}
|
||||
|
||||
function selectGateway() {
|
||||
setSetupMode('gateway');
|
||||
if (clientSettings?.sharedProxyControlUrl) {
|
||||
return onCheckSharedProxy(clientSettings.sharedProxyControlUrl);
|
||||
}
|
||||
return null;
|
||||
function selectServer(tag) {
|
||||
setPendingTag(tag);
|
||||
if (connected && tag) onApply(tag);
|
||||
}
|
||||
|
||||
function selectVpn() {
|
||||
setSetupMode('vpn');
|
||||
if (state?.selectedTag) {
|
||||
return onApply(state.selectedTag);
|
||||
async function submitSubscription(event) {
|
||||
event.preventDefault();
|
||||
if (!subscriptionUrl.trim()) return;
|
||||
await onFetchSubscription();
|
||||
setSubscriptionUrl('');
|
||||
setEditingSubscription(false);
|
||||
}
|
||||
|
||||
async function copyProxy(kind) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(proxyUrls[kind]);
|
||||
setCopiedProxy(kind);
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
} catch {
|
||||
setCopiedProxy('error');
|
||||
setTimeout(() => setCopiedProxy(''), 1400);
|
||||
}
|
||||
return onSaveClientSettings({
|
||||
...clientSettings,
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="client-dashboard">
|
||||
<StatusPanel route={route} state={state} />
|
||||
<RouteLine route={route} />
|
||||
<div className="client-shell">
|
||||
<main className="client-panel">
|
||||
<section className="client-power-section" aria-labelledby="connection-title">
|
||||
<button
|
||||
className="client-power"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={connected}
|
||||
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
|
||||
disabled={busy || (!connected && !canStart)}
|
||||
onClick={toggleConnection}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
|
||||
{connected && (
|
||||
<time className="client-duration">
|
||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||
</time>
|
||||
)}
|
||||
{!connected && (
|
||||
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="client-workspace">
|
||||
<div className="client-main-panel">
|
||||
<div className="client-mode-grid">
|
||||
<ModeButton
|
||||
active={route.mode === 'gateway'}
|
||||
selected={setupMode === 'gateway'}
|
||||
title="Общий gateway"
|
||||
subtitle={clientSettings?.sharedProxy ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` : 'серверная proxy'}
|
||||
disabled={busy}
|
||||
onClick={selectGateway}
|
||||
/>
|
||||
<ModeButton
|
||||
active={route.mode === 'vpn'}
|
||||
selected={setupMode === 'vpn'}
|
||||
title="Локальный VPN"
|
||||
subtitle={state?.selectedTag || 'выбрать сервер'}
|
||||
disabled={busy}
|
||||
onClick={selectVpn}
|
||||
/>
|
||||
<ModeButton
|
||||
active={route.mode === 'direct'}
|
||||
selected={setupMode === 'direct'}
|
||||
title="Напрямую"
|
||||
subtitle="без VPN"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSetupMode('direct');
|
||||
enableDirect();
|
||||
<div className="client-form">
|
||||
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
|
||||
<button
|
||||
className="client-subscription-summary"
|
||||
type="button"
|
||||
aria-hidden={editingSubscription}
|
||||
inert={editingSubscription ? true : undefined}
|
||||
tabIndex={editingSubscription ? -1 : 0}
|
||||
onClick={() => setEditingSubscription(true)}
|
||||
>
|
||||
<span>Ваша подписка</span>
|
||||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||
</button>
|
||||
|
||||
<form
|
||||
className="client-subscription-edit"
|
||||
aria-hidden={!editingSubscription}
|
||||
inert={!editingSubscription ? true : undefined}
|
||||
onSubmit={submitSubscription}
|
||||
>
|
||||
<input
|
||||
ref={subscriptionInputRef}
|
||||
id="subscription-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="url"
|
||||
tabIndex={editingSubscription ? 0 : -1}
|
||||
aria-label="Ссылка подписки"
|
||||
placeholder="Вставьте ссылку подписки"
|
||||
value={subscriptionUrl}
|
||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && state?.hasSubscription) {
|
||||
setSubscriptionUrl('');
|
||||
setEditingSubscription(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{subscriptionUrl.trim() && (
|
||||
<button type="submit" aria-label="Сохранить подписку" disabled={busy}>✓</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{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} />
|
||||
)}
|
||||
</div>
|
||||
<section className="client-servers" aria-label="Серверы">
|
||||
<div className="client-server-grid">
|
||||
{servers.map((server) => {
|
||||
const ping = pings[server.tag];
|
||||
const selected = server.tag === selectedTag;
|
||||
const pingText = ping?.checking
|
||||
? 'Проверка…'
|
||||
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
|
||||
const pingClass = ping?.ok
|
||||
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
|
||||
: '';
|
||||
|
||||
<ProxySettings
|
||||
state={state}
|
||||
settings={clientSettings}
|
||||
busy={busy}
|
||||
onSave={onSaveClientSettings}
|
||||
/>
|
||||
return (
|
||||
<button
|
||||
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||
type="button"
|
||||
key={server.tag}
|
||||
disabled={busy}
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectServer(server.tag)}
|
||||
>
|
||||
<strong>{server.tag}</strong>
|
||||
<small className={pingClass}>{pingText}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section className="client-proxies" aria-label="Локальный прокси">
|
||||
{[
|
||||
['socks5', 'SOCKS5'],
|
||||
['http', 'HTTP'],
|
||||
].map(([kind, label]) => (
|
||||
<button
|
||||
className={copiedProxy === kind ? 'is-copied' : ''}
|
||||
type="button"
|
||||
key={kind}
|
||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||
onClick={() => copyProxy(kind)}
|
||||
>
|
||||
<span>
|
||||
{copiedProxy === kind
|
||||
? 'Скопировано ✓'
|
||||
: copiedProxy === 'error' ? 'Ошибка копирования' : label}
|
||||
</span>
|
||||
<strong>{proxyUrls[kind]}</strong>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ const OUTBOUND_KIND = {
|
||||
};
|
||||
|
||||
const DEVICE_MODES = {
|
||||
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
|
||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||
@@ -28,6 +29,7 @@ const DEVICE_MODES = {
|
||||
function DeviceModeSelect({ value, onChange }) {
|
||||
return (
|
||||
<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="vpn">VPN</option>
|
||||
<option value="rules">default</option>
|
||||
@@ -46,7 +48,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h2>Устройства</h2>
|
||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
||||
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы — fallback после global rules.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||
|
||||
@@ -8,16 +8,8 @@ const NAV = [
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
const WINDOWS_NAV = [
|
||||
{ id: 'overview', label: 'Overview', ico: 'O' },
|
||||
{ id: 'logs', label: 'Logs', ico: 'L' },
|
||||
{ id: 'settings', label: 'Settings', ico: 'S' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'windows'
|
||||
? WINDOWS_NAV
|
||||
: mode === 'client'
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
|
||||
@@ -26,22 +26,17 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
const isWindows = state?.mode === 'windows';
|
||||
const brand = isWindows ? 'VPN Proxy Windows' : isClient ? 'VPN Client' : 'VPN Gateway';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
{brand}
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
<StatusBadge status={status} />
|
||||
{isWindows && (
|
||||
<small className="muted">App profiles and ProxiFyre routing</small>
|
||||
)}
|
||||
{!isWindows && activeServer && (
|
||||
{activeServer && (
|
||||
<div className="status-text">
|
||||
<strong>
|
||||
{flagFor(activeServer)} {activeServer.tag}
|
||||
@@ -52,22 +47,21 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!isWindows && !activeServer && (
|
||||
{!activeServer && (
|
||||
<small className="muted">Сервер не выбран</small>
|
||||
)}
|
||||
{!isWindows && traffic && <span className="badge neutral">{traffic}</span>}
|
||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!isClient && !isWindows && dirty && (
|
||||
{!isClient && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{!isClient && !isWindows && state?.previousTag && (
|
||||
{!isClient && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
)}
|
||||
{!isWindows && (
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
@@ -76,7 +70,6 @@ export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApp
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function targetLabel(target) {
|
||||
if (!target) return 'No proxy target';
|
||||
return `${target.name} - ${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function routeTitle(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
const singbox = helper?.result?.singbox || helper?.singbox;
|
||||
if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box';
|
||||
if (proxifyre === 'Running') return 'Apps are routed through an existing proxy';
|
||||
return 'App routing is stopped';
|
||||
}
|
||||
|
||||
function routeState(status) {
|
||||
const helper = status?.helperStatus;
|
||||
const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
|
||||
if (proxifyre === 'Running') return 'running';
|
||||
if (helper?.success === false) return 'error';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
function emptyProfile() {
|
||||
return {
|
||||
id: `profile-${Date.now()}`,
|
||||
name: 'New profile',
|
||||
enabled: true,
|
||||
proxyTargetId: 'local-singbox',
|
||||
protocols: ['TCP', 'UDP'],
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function ProfileList({ profiles, selectedId, onSelect }) {
|
||||
return (
|
||||
<div className="win-profile-list">
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
|
||||
onClick={() => onSelect(profile.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
|
||||
<span>
|
||||
<strong>{profile.name}</strong>
|
||||
<small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
|
||||
</span>
|
||||
<em>{profile.resolvedCount ?? profile.items.length}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDetails({ profile, targets, onChange }) {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const [newType, setNewType] = useState('process');
|
||||
if (!profile) {
|
||||
return <div className="win-profile-empty">Select or add a profile.</div>;
|
||||
}
|
||||
function patch(patchValue) {
|
||||
onChange({ ...profile, ...patchValue });
|
||||
}
|
||||
function addItem() {
|
||||
const value = newItem.trim();
|
||||
if (!value) return;
|
||||
patch({
|
||||
items: [
|
||||
...profile.items,
|
||||
{ type: newType, value, recursive: newType === 'folder' },
|
||||
],
|
||||
});
|
||||
setNewItem('');
|
||||
}
|
||||
return (
|
||||
<div className="win-detail">
|
||||
<label className="checkbox win-enabled">
|
||||
<input
|
||||
checked={profile.enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
Enabled profile
|
||||
</label>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={profile.name}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Proxy target</span>
|
||||
<select
|
||||
className="select"
|
||||
value={profile.proxyTargetId}
|
||||
onChange={(event) => patch({ proxyTargetId: event.target.value })}
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<option key={target.id} value={target.id}>{targetLabel(target)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="win-add-item">
|
||||
<select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
|
||||
<option value="process">Process</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="exe">EXE file</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
value={newItem}
|
||||
placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
|
||||
onChange={(event) => setNewItem(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && addItem()}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={addItem} type="button">Add</button>
|
||||
</div>
|
||||
<div className="win-items">
|
||||
{profile.items.map((item, index) => (
|
||||
<div key={`${item.type}-${item.value}-${index}`} className="win-item">
|
||||
<span>{item.value}</span>
|
||||
<small>{item.type}</small>
|
||||
<button
|
||||
className="btn btn-link sm"
|
||||
onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsOverviewPage({ pushToast }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const data = await api.windows.status();
|
||||
setStatus(data);
|
||||
const nextProfiles = data.profiles || [];
|
||||
setProfiles(nextProfiles);
|
||||
setTargets(data.targets || []);
|
||||
setSelectedId((current) => current || nextProfiles[0]?.id || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message }));
|
||||
const timer = setInterval(() => load().catch(() => {}), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const selected = useMemo(
|
||||
() => profiles.find((profile) => profile.id === selectedId) || null,
|
||||
[profiles, selectedId],
|
||||
);
|
||||
|
||||
function replaceProfile(nextProfile) {
|
||||
setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile));
|
||||
}
|
||||
|
||||
async function saveProfiles(nextProfiles = profiles) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.windows.profiles.save(nextProfiles);
|
||||
setProfiles(data.summaries || data.profiles || []);
|
||||
pushToast?.({ kind: 'success', title: 'Profiles saved' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Save failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProfiles() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.windows.profiles.save(profiles);
|
||||
await api.windows.profiles.apply();
|
||||
await load();
|
||||
pushToast?.({ kind: 'success', title: 'ProxiFyre updated' });
|
||||
} catch (error) {
|
||||
pushToast?.({ kind: 'danger', title: 'Apply failed', message: error.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addProfile() {
|
||||
const profile = emptyProfile();
|
||||
setProfiles((prev) => [...prev, profile]);
|
||||
setSelectedId(profile.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="windows-page">
|
||||
<section className="windows-status-panel">
|
||||
<div className="windows-status-main">
|
||||
<span className={`windows-status-dot ${routeState(status)}`} />
|
||||
<div>
|
||||
<h1>{routeTitle(status)}</h1>
|
||||
<p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="windows-route-line">
|
||||
<span>Selected apps</span><b>-></b><span>ProxiFyre</span><b>-></b><span>Proxy target</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="windows-workspace">
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>Profiles</h2>
|
||||
<small>{profiles.filter((profile) => profile.enabled).length} enabled</small>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={addProfile} type="button">Add profile</button>
|
||||
</div>
|
||||
<ProfileList profiles={profiles} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>{selected?.name || 'Profile'}</h2>
|
||||
<small>{selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'}</small>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={applyProfiles} type="button">Apply changes</button>
|
||||
</div>
|
||||
<ProfileDetails profile={selected} targets={targets} onChange={replaceProfile} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel windows-activity">
|
||||
<div className="panel-head">
|
||||
<h2>Recent activity</h2>
|
||||
<button className="btn btn-secondary" disabled={busy} onClick={() => saveProfiles()} type="button">Save only</button>
|
||||
</div>
|
||||
{(status?.activity || []).slice(0, 5).map((entry) => (
|
||||
<div key={entry.id} className="windows-activity-row">
|
||||
<strong>{entry.type}</strong>
|
||||
<span>{entry.message}</span>
|
||||
<small>{entry.ts}</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,16 @@ html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
|
||||
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
@@ -830,557 +840,418 @@ code, .mono {
|
||||
|
||||
/* ============ Client overview ============ */
|
||||
|
||||
.app.client-app {
|
||||
--client-bg: oklch(0.965 0.006 145);
|
||||
--client-panel: oklch(0.995 0.003 145);
|
||||
--client-control: oklch(0.955 0.006 145);
|
||||
--client-border: oklch(0.86 0.012 145);
|
||||
--client-text: oklch(0.24 0.014 145);
|
||||
--client-muted: oklch(0.53 0.014 145);
|
||||
--client-accent: oklch(0.62 0.14 151);
|
||||
--client-accent-soft: oklch(0.92 0.045 151);
|
||||
grid-template-rows: 1fr;
|
||||
color-scheme: light;
|
||||
background: var(--client-bg);
|
||||
}
|
||||
|
||||
.app-body.client-mode {
|
||||
background: var(--client-bg);
|
||||
}
|
||||
|
||||
.client-mode .app-main {
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.client-dashboard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(420px, 0.8fr);
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.client-status-panel.connected { border-color: rgba(109, 255, 157, 0.46); }
|
||||
.client-status-panel.stopped { border-color: rgba(255, 209, 102, 0.42); }
|
||||
.client-status-panel.empty { border-color: rgba(142, 212, 255, 0.32); }
|
||||
|
||||
.client-status-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
.client-status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--subtle);
|
||||
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
.client-status-dot.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
|
||||
}
|
||||
.client-status-dot.stopped {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
|
||||
}
|
||||
.client-status-dot.empty {
|
||||
background: var(--info);
|
||||
box-shadow: 0 0 0 6px rgba(142, 212, 255, 0.12);
|
||||
}
|
||||
.client-eyebrow {
|
||||
color: var(--subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.client-status-main h1 {
|
||||
margin: 2px 0 4px;
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.client-status-main p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-status-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.client-status-facts > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.client-status-facts small,
|
||||
.client-current-target small,
|
||||
.client-panel-title {
|
||||
display: block;
|
||||
color: var(--subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.client-status-facts strong,
|
||||
.client-current-target strong {
|
||||
display: block;
|
||||
margin: 3px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.client-status-facts span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-route-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.client-route-line span {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.client-route-line b {
|
||||
color: var(--subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.client-main-panel,
|
||||
.client-side-panel {
|
||||
background: #101820;
|
||||
border: 1px solid #263442;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.client-main-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.client-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.client-mode-button {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid #2a3948;
|
||||
border-radius: 8px;
|
||||
background: #0b1219;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.client-mode-button:hover:not(:disabled) {
|
||||
border-color: #4c6d88;
|
||||
background: #101c27;
|
||||
}
|
||||
.client-mode-button.selected {
|
||||
border-color: var(--info);
|
||||
background: rgba(142, 212, 255, 0.08);
|
||||
}
|
||||
.client-mode-button.active {
|
||||
border-color: var(--success);
|
||||
background: rgba(109, 255, 157, 0.11);
|
||||
}
|
||||
.client-mode-button strong,
|
||||
.client-mode-button span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.client-mode-button strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
.client-mode-button span {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-mode-settings {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.client-mode-settings.direct {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.client-mode-settings.direct p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.client-inline-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-current-target {
|
||||
padding: 10px 12px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-side-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.client-copy-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.client-copy {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.client-copy span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.client-copy strong {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.client-port-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.client-status-panel,
|
||||
.client-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.client-status-facts,
|
||||
.client-mode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.client-mode-settings.direct,
|
||||
.client-inline-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 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;
|
||||
padding: 48px 24px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.win-profile-check.on {
|
||||
background: rgba(109, 255, 157, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.win-detail {
|
||||
.client-shell {
|
||||
width: min(100%, 440px);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
color: var(--client-text);
|
||||
}
|
||||
|
||||
.win-detail label:not(.checkbox) {
|
||||
.client-panel {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
gap: 30px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.client-power-section p {
|
||||
color: var(--client-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.client-power-section {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-power {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--client-border);
|
||||
border-radius: 50%;
|
||||
background: var(--client-control);
|
||||
color: var(--client-muted);
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.client-power:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
color: var(--client-text);
|
||||
border-color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-power[aria-checked='true'] {
|
||||
color: var(--client-accent);
|
||||
background: var(--client-accent-soft);
|
||||
border-color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-power:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.client-power svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.client-power-section h2 {
|
||||
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.client-duration {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--client-text);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.client-power-section p {
|
||||
min-height: 17px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.client-form {
|
||||
display: grid;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.client-subscription {
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.client-subscription-edit,
|
||||
.client-subscription-summary {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-subscription-edit {
|
||||
width: min(100%, 380px);
|
||||
min-height: 44px;
|
||||
display: block;
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
filter: blur(16px);
|
||||
transform: translateY(6px) scale(0.96);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-subscription-edit::before,
|
||||
.client-subscription-edit::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-subscription-edit::before {
|
||||
background: var(--client-border);
|
||||
}
|
||||
|
||||
.client-subscription-edit::after {
|
||||
background: var(--client-accent);
|
||||
opacity: 0.72;
|
||||
filter: blur(0.5px);
|
||||
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||
}
|
||||
|
||||
.client-subscription.is-editing .client-subscription-edit {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.client-subscription-edit input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
padding: 0 42px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
caret-color: var(--client-accent);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-subscription-edit input::placeholder {
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-subscription-edit button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-accent);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-subscription-summary {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--client-muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-subscription.is-editing .client-subscription-summary {
|
||||
opacity: 0;
|
||||
filter: blur(18px);
|
||||
transform: translateY(-4px) scale(1.06);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-subscription-summary span {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.client-subscription-summary strong {
|
||||
color: var(--client-text);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.055em;
|
||||
opacity: 0.88;
|
||||
text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24);
|
||||
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-subscription-summary:hover strong {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
||||
}
|
||||
|
||||
.client-servers {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.client-server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-server {
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 7px 4px 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.client-server:hover:not(:disabled) {
|
||||
transform: none;
|
||||
background: transparent;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.client-server.is-selected {
|
||||
border-bottom: 2px solid var(--client-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.client-server strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-server small {
|
||||
color: var(--client-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-server small.good { color: var(--client-accent); }
|
||||
.client-server small.medium { color: oklch(0.72 0.12 80); }
|
||||
.client-server small.slow { color: oklch(0.65 0.16 30); }
|
||||
|
||||
.client-server:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.client-subscription-edit button:focus-visible,
|
||||
.client-subscription-summary:focus-visible,
|
||||
.client-server:focus-visible,
|
||||
.client-power:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-proxies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.client-proxies button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 6px 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
opacity: 0.76;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.client-proxies button:hover {
|
||||
transform: translateY(-1px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.client-proxies button.is-copied {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.client-proxies span {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.client-proxies strong {
|
||||
overflow: hidden;
|
||||
color: var(--client-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.win-enabled {
|
||||
justify-self: start;
|
||||
.client-proxies button.is-copied span {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
.client-proxies button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.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 (prefers-color-scheme: dark) {
|
||||
.app.client-app {
|
||||
--client-bg: oklch(0.18 0.012 145);
|
||||
--client-panel: oklch(0.22 0.012 145);
|
||||
--client-control: oklch(0.26 0.012 145);
|
||||
--client-border: oklch(0.34 0.015 145);
|
||||
--client-text: oklch(0.93 0.008 145);
|
||||
--client-muted: oklch(0.68 0.012 145);
|
||||
--client-accent: oklch(0.76 0.13 151);
|
||||
--client-accent-soft: oklch(0.32 0.055 151);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.windows-status-panel,
|
||||
.windows-workspace,
|
||||
.windows-activity-row {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 560px) {
|
||||
.client-mode .app-main {
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.win-add-item {
|
||||
.client-panel {
|
||||
gap: 26px;
|
||||
padding: 20px 6px;
|
||||
}
|
||||
|
||||
.client-server-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.client-proxies {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-body.windows-mode {
|
||||
grid-template-columns: 1fr;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-power,
|
||||
.client-server,
|
||||
.client-proxies button,
|
||||
.client-subscription-edit,
|
||||
.client-subscription-summary,
|
||||
.client-subscription-summary strong {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
src/web/utils/clientControls.js
Normal file
31
src/web/utils/clientControls.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export function connectionAction({ connected, selectedTag, configExists }) {
|
||||
if (connected) return { type: 'stop' };
|
||||
if (selectedTag) return { type: 'apply', selectedTag };
|
||||
if (configExists) return { type: 'restart' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatConnectionDuration(startedAt, now = Date.now()) {
|
||||
const started = Date.parse(startedAt);
|
||||
const totalSeconds = Number.isFinite(started)
|
||||
? Math.max(0, Math.floor((now - started) / 1000))
|
||||
: 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return [hours, minutes, seconds]
|
||||
.map((part) => String(part).padStart(2, '0'))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
export function subscriptionDomain(subscriptionHost) {
|
||||
return String(subscriptionHost || '').split('/')[0];
|
||||
}
|
||||
|
||||
export function localProxyUrls(port = 8082) {
|
||||
return {
|
||||
socks5: `socks5://127.0.0.1:${port}`,
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export function resolveClientRoute({ state, activeServer } = {}) {
|
||||
const settings = state?.clientSettings || {};
|
||||
const localProxy = `127.0.0.1:${state?.proxyPort || settings.proxyPort || 8080}`;
|
||||
const running = Boolean(state?.singboxRunning);
|
||||
const hasConfig = Boolean(state?.configExists);
|
||||
|
||||
let mode = "none";
|
||||
let target = "выберите режим";
|
||||
let targetDetail = "Gateway, локальный VPN или напрямую";
|
||||
let title = "Не подключено";
|
||||
let description = "Выберите режим подключения и примените его.";
|
||||
let pathTarget = "не выбран";
|
||||
|
||||
if (settings.sharedProxyEnabled && settings.sharedProxy) {
|
||||
mode = "gateway";
|
||||
target = `${settings.sharedProxy.host}:${settings.sharedProxy.port}`;
|
||||
targetDetail = "общий gateway proxy";
|
||||
title = running ? "Подключено к gateway" : "Gateway настроен, но остановлен";
|
||||
description = "Локальный proxy на Mac отправляет трафик на серверный gateway.";
|
||||
pathTarget = `Gateway ${target}`;
|
||||
} else if (settings.homeBypassEnabled) {
|
||||
mode = "direct";
|
||||
target = "без VPN";
|
||||
targetDetail = "прямое подключение";
|
||||
title = running ? "Подключено напрямую" : "Direct настроен, но остановлен";
|
||||
description = "Приложения используют локальный proxy, но трафик идет напрямую.";
|
||||
pathTarget = "Direct";
|
||||
} else if (state?.selectedTag) {
|
||||
mode = "vpn";
|
||||
target = activeServer?.tag || state.selectedTag;
|
||||
targetDetail = "локальный VPN";
|
||||
title = running ? "Подключено через VPN" : "VPN настроен, но остановлен";
|
||||
description = "Локальный proxy на Mac отправляет трафик через выбранный VPN-сервер.";
|
||||
pathTarget = `VPN ${target}`;
|
||||
}
|
||||
|
||||
const status = running
|
||||
? "connected"
|
||||
: hasConfig && mode !== "none"
|
||||
? "stopped"
|
||||
: "empty";
|
||||
|
||||
if (status === "empty") {
|
||||
title = "Не подключено";
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
status,
|
||||
localProxy,
|
||||
title,
|
||||
target,
|
||||
targetDetail,
|
||||
description,
|
||||
path: ["Mac apps", localProxy, pathTarget, "Internet"],
|
||||
};
|
||||
}
|
||||
65
test/server/client-config.test.js
Normal file
65
test/server/client-config.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("client proxy defaults to 8082 when no port is configured", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
APP_MODE: "client",
|
||||
PROXY_PORT: undefined,
|
||||
CLIENT_PROXY_PORT_START: undefined,
|
||||
CLIENT_PROXY_PORT_END: undefined,
|
||||
},
|
||||
async () => {
|
||||
const { settings } = await import(
|
||||
`../../src/server/config.js?client-default-port=${Date.now()}`
|
||||
);
|
||||
|
||||
assert.equal(settings.proxyPort, 8082);
|
||||
assert.equal(settings.clientProxyPortStart, 8082);
|
||||
assert.equal(settings.clientProxyPortEnd, 8082);
|
||||
},
|
||||
);
|
||||
});
|
||||
167
test/server/device-bypass-mode.test.js
Normal file
167
test/server/device-bypass-mode.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
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",
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
122
test/server/entrypoint-tproxy-bypass.test.js
Normal file
122
test/server/entrypoint-tproxy-bypass.test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ test("client mode exposes only the local mixed proxy inbound", () => {
|
||||
["mixed-in"],
|
||||
);
|
||||
assert.equal(config.inbounds[0].type, "mixed");
|
||||
assert.equal(config.inbounds[0].listen_port, 8080);
|
||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||
});
|
||||
|
||||
test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||
@@ -44,6 +44,7 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
assert.equal(config.route.auto_detect_interface, undefined);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||
]);
|
||||
@@ -82,7 +83,7 @@ test("client home bypass can build direct proxy without local VPN", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("client mode uses selected proxy port from client settings", () => {
|
||||
test("client mode ignores saved proxy port outside the published single port", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
clientSettingsPath,
|
||||
@@ -91,7 +92,7 @@ test("client mode uses selected proxy port from client settings", () => {
|
||||
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
assert.equal(config.inbounds[0].listen_port, 8085);
|
||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||
assert.deepEqual(config.route.rules, [
|
||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||
]);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
12
test/server/subscription.test.js
Normal file
12
test/server/subscription.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseSubscriptionBody } from '../../src/server/subscription.js';
|
||||
|
||||
test('subscription server tags are trimmed for selection', () => {
|
||||
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||
}));
|
||||
|
||||
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
46
test/web/client-controls.test.js
Normal file
46
test/web/client-controls.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
connectionAction,
|
||||
formatConnectionDuration,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
} from '../../src/web/utils/clientControls.js';
|
||||
|
||||
test('connection button chooses the only valid client action', () => {
|
||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||
type: 'apply',
|
||||
selectedTag: 'nl-amsterdam',
|
||||
});
|
||||
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||
assert.equal(connectionAction({}), null);
|
||||
});
|
||||
|
||||
test('connection duration is derived from the backend start time', () => {
|
||||
const startedAt = '2026-07-10T10:00:00.000Z';
|
||||
const now = Date.parse('2026-07-11T12:03:04.900Z');
|
||||
|
||||
assert.equal(formatConnectionDuration(startedAt, now), '26:03:04');
|
||||
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
||||
});
|
||||
|
||||
test('saved subscription is reduced to its public domain', () => {
|
||||
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
||||
assert.equal(subscriptionDomain(''), '');
|
||||
});
|
||||
|
||||
test('local proxy exposes both supported URLs', () => {
|
||||
assert.deepEqual(localProxyUrls(18080), {
|
||||
socks5: 'socks5://127.0.0.1:18080',
|
||||
http: 'http://127.0.0.1:18080',
|
||||
});
|
||||
});
|
||||
|
||||
test('local proxy defaults to the macOS client port', () => {
|
||||
assert.deepEqual(localProxyUrls(), {
|
||||
socks5: 'socks5://127.0.0.1:8082',
|
||||
http: 'http://127.0.0.1:8082',
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveClientRoute } from "../../src/web/utils/clientRoute.js";
|
||||
|
||||
test("shows gateway route as the active Mac connection", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 18080,
|
||||
clientSettings: {
|
||||
sharedProxyEnabled: true,
|
||||
sharedProxy: { host: "192.168.50.111", port: 8080, protocol: "socks5" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "gateway");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено к gateway");
|
||||
assert.equal(route.target, "192.168.50.111:8080");
|
||||
assert.deepEqual(route.path, [
|
||||
"Mac apps",
|
||||
"127.0.0.1:18080",
|
||||
"Gateway 192.168.50.111:8080",
|
||||
"Internet",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows local VPN route with selected server", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 8082,
|
||||
selectedTag: "nl-amsterdam",
|
||||
clientSettings: {},
|
||||
},
|
||||
activeServer: { tag: "nl-amsterdam", country: "NL" },
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "vpn");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено через VPN");
|
||||
assert.equal(route.target, "nl-amsterdam");
|
||||
});
|
||||
|
||||
test("shows direct route when home mode is enabled", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: true,
|
||||
proxyPort: 8082,
|
||||
clientSettings: { homeBypassEnabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "direct");
|
||||
assert.equal(route.status, "connected");
|
||||
assert.equal(route.title, "Подключено напрямую");
|
||||
assert.equal(route.target, "без VPN");
|
||||
});
|
||||
|
||||
test("shows configured but stopped route clearly", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: false,
|
||||
configExists: true,
|
||||
proxyPort: 8082,
|
||||
selectedTag: "nl-amsterdam",
|
||||
clientSettings: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "vpn");
|
||||
assert.equal(route.status, "stopped");
|
||||
assert.equal(route.title, "VPN настроен, но остановлен");
|
||||
});
|
||||
|
||||
test("shows missing setup when nothing is configured", () => {
|
||||
const route = resolveClientRoute({
|
||||
state: {
|
||||
singboxRunning: false,
|
||||
configExists: false,
|
||||
proxyPort: 8082,
|
||||
clientSettings: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(route.mode, "none");
|
||||
assert.equal(route.status, "empty");
|
||||
assert.equal(route.title, "Не подключено");
|
||||
assert.equal(route.target, "выберите режим");
|
||||
});
|
||||
Reference in New Issue
Block a user