Compare commits
22 Commits
2.0.0
...
99f7f58fcb
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f7f58fcb | |||
| 6bc7840fb1 | |||
| d3b7f0d613 | |||
| efa46d1ee5 | |||
| 149bb999dc | |||
| 288acbf0c8 | |||
| b45dd2ae05 | |||
| c5bdb10445 | |||
| 7dbf786c56 | |||
| 59f2264a2e | |||
| a0f41baa36 | |||
| c3d3aaa699 | |||
| 301b76c03e | |||
| ab6de6996f | |||
| 0092ec4cde | |||
| 12ad0c8b78 | |||
| b5d4c61783 | |||
| f4990a4f55 | |||
| ab44626a0f | |||
| 95edefa84f | |||
| f914c28bc5 | |||
| 73488384e4 |
@@ -1,7 +1,8 @@
|
|||||||
PORT=3456
|
PORT=3456
|
||||||
APP_MODE=gateway
|
APP_MODE=gateway
|
||||||
CLIENT_UI_PORT=3456
|
CLIENT_UI_PORT=3456
|
||||||
CLIENT_PROXY_PORT=8080
|
CLIENT_PROXY_PORT_START=8082
|
||||||
|
CLIENT_PROXY_PORT_END=8082
|
||||||
BASE_IMAGE=debian:bookworm-slim
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
SINGBOX_VERSION=1.12.13
|
SINGBOX_VERSION=1.12.13
|
||||||
INSTALL_RUNTIME_DEPS=true
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,8 @@ _archive/
|
|||||||
*.env.local
|
*.env.local
|
||||||
data/
|
data/
|
||||||
.vpn-proxy/
|
.vpn-proxy/
|
||||||
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
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 \
|
ENV APP_MODE=client \
|
||||||
PORT=3456 \
|
PORT=3456 \
|
||||||
PROXY_PORT=8080 \
|
PROXY_PORT=8082 \
|
||||||
PROXY_BIND_IP=0.0.0.0 \
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
DATA_DIR=/var/lib/vpn-proxy \
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
@@ -49,6 +49,6 @@ ENV APP_MODE=client \
|
|||||||
ROUTING_RU_DIRECT=true \
|
ROUTING_RU_DIRECT=true \
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
EXPOSE 3456 8080
|
EXPOSE 3456 8082
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
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.
|
||||||
66
README.md
66
README.md
@@ -10,12 +10,34 @@
|
|||||||
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
После запуска:
|
После запуска по умолчанию:
|
||||||
|
|
||||||
- UI: `http://127.0.0.1:3456`
|
- UI: `http://127.0.0.1:3456`
|
||||||
- HTTP/SOCKS proxy: `127.0.0.1:8080`
|
- HTTP/SOCKS proxy: `127.0.0.1:8082` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
|
||||||
|
|
||||||
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN.
|
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_UI_PORT=3457 VPN_PROXY_CLIENT_PORT=18080 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
|
||||||
|
|
||||||
|
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
|
||||||
|
|
||||||
|
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET http://<gateway-ui-host>:3456/api/shared-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
|
||||||
|
|
||||||
Ручной запуск из checkout:
|
Ручной запуск из checkout:
|
||||||
|
|
||||||
@@ -31,6 +53,22 @@ docker compose -f docker-compose.client.yml logs -f
|
|||||||
docker compose -f docker-compose.client.yml restart
|
docker compose -f docker-compose.client.yml restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Windows Desktop Client
|
||||||
|
|
||||||
|
The Windows desktop client has been split out of this repository. Continue
|
||||||
|
Windows-specific work in the sibling repository:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\repos\ProxyWarden
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# VPN Proxy Gateway
|
# VPN Proxy Gateway
|
||||||
@@ -53,6 +91,8 @@ docker compose -f docker-compose.client.yml restart
|
|||||||
▼
|
▼
|
||||||
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||||
│
|
│
|
||||||
|
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
|
||||||
|
│ └─ FORWARD + MASQUERADE → обычный internet path
|
||||||
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
||||||
├─ приватные CIDR (RFC1918, ...) → RETURN
|
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||||
└─ TCP/UDP → TPROXY :7895
|
└─ TCP/UDP → TPROXY :7895
|
||||||
@@ -104,8 +144,12 @@ ip route replace local 0.0.0.0/0 dev lo table 100
|
|||||||
|
|
||||||
# Цепочка iptables (порядок правил — критичен)
|
# Цепочка iptables (порядок правил — критичен)
|
||||||
iptables -t mangle -N VPN_PROXY_TPROXY
|
iptables -t mangle -N VPN_PROXY_TPROXY
|
||||||
|
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
|
||||||
|
iptables -N VPN_PROXY_FWD_BYPASS
|
||||||
|
iptables -t nat -N VPN_PROXY_NAT_BYPASS
|
||||||
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
||||||
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
||||||
|
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
|
||||||
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
||||||
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||||
-p tcp → TPROXY :7895 mark 1
|
-p tcp → TPROXY :7895 mark 1
|
||||||
@@ -116,6 +160,10 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
|||||||
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||||
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||||
|
|
||||||
|
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
|
||||||
|
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
|
||||||
|
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
|
||||||
|
|
||||||
### 2. Маршрутизация внутри sing-box
|
### 2. Маршрутизация внутри sing-box
|
||||||
|
|
||||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||||
@@ -343,7 +391,12 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| ------------------- | -------------------- | -------------------------------------- |
|
| ------------------- | -------------------- | -------------------------------------- |
|
||||||
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
||||||
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
||||||
| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `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` и 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` | Порт веб-интерфейса |
|
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||||
@@ -351,6 +404,10 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
|
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
|
||||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||||
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||||
|
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
|
||||||
|
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
|
||||||
|
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
|
||||||
|
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
|
||||||
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
|
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
|
||||||
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||||
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||||
@@ -369,6 +426,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
| --------- | ---------------------- | ------------------------------------ |
|
| --------- | ---------------------- | ------------------------------------ |
|
||||||
| `GET` | `/api/state` | Полное состояние системы |
|
| `GET` | `/api/state` | Полное состояние системы |
|
||||||
|
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_MODE: client
|
APP_MODE: client
|
||||||
PORT: ${PORT:-3456}
|
PORT: ${PORT:-3456}
|
||||||
PROXY_PORT: ${PROXY_PORT:-8080}
|
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
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
@@ -17,15 +19,23 @@ services:
|
|||||||
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
||||||
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
ALL_PROXY: ""
|
||||||
|
http_proxy: ""
|
||||||
|
https_proxy: ""
|
||||||
|
all_proxy: ""
|
||||||
|
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||||
- "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
|
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}"
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
- sing-box-client-cache:/var/lib/sing-box
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
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 | делаем первым |
|
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | 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
|
## Gateway mode
|
||||||
|
|
||||||
@@ -84,15 +84,9 @@
|
|||||||
|
|
||||||
## Windows gaming mode
|
## Windows gaming mode
|
||||||
|
|
||||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
|
||||||
|
|
||||||
Требования:
|
Статус: вынесено в соседний репозиторий `D:\repos\ProxyWarden`. Этот репозиторий больше не содержит Tauri Windows app, Windows-specific планы/evidence или installer scripts.
|
||||||
|
|
||||||
- Native `sing-box.exe`.
|
|
||||||
- Scheduled task или Windows service.
|
|
||||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
|
||||||
- Управление из PowerShell helper.
|
|
||||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
|
||||||
|
|
||||||
## Рабочий порядок
|
## Рабочий порядок
|
||||||
|
|
||||||
@@ -102,4 +96,4 @@
|
|||||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||||
5. Добавить gateway docs/install script.
|
5. Добавить gateway docs/install script.
|
||||||
6. Потом переносить desktop-proxy.
|
6. Потом переносить desktop-proxy.
|
||||||
7. Потом приводить Windows mode к новой архитектуре.
|
7. Windows desktop client развивать в `D:\repos\ProxyWarden`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PORT="${PORT:-3456}"
|
PORT="${PORT:-3456}"
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||||
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
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_MARK="${TPROXY_MARK:-1}"
|
||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
|
||||||
|
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
|
||||||
|
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
|
||||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
||||||
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
||||||
@@ -36,13 +40,32 @@ cleanup_proxy_firewall() {
|
|||||||
cleanup_tproxy() {
|
cleanup_tproxy() {
|
||||||
log "cleanup tproxy rules"
|
log "cleanup tproxy rules"
|
||||||
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -X "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
# ipset не чистим при завершении — TTL сам истечёт
|
# ipset не чистим при завершении — TTL сам истечёт
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
log "enable IPv4 forwarding for source bypass"
|
||||||
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v sysctl >/dev/null 2>&1; then
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
setup_direct_bypass_set() {
|
setup_direct_bypass_set() {
|
||||||
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
|
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
|
||||||
export DIRECT_BYPASS_CACHE
|
export DIRECT_BYPASS_CACHE
|
||||||
@@ -76,14 +99,32 @@ setup_proxy_firewall() {
|
|||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
|
||||||
ipt -t mangle -N "$TPROXY_CHAIN"
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
|
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
|
||||||
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
|
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
|
||||||
|
|
||||||
|
for cidr in $BYPASS_CIDRS; do
|
||||||
|
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
|
||||||
|
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
|
||||||
|
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
|
||||||
|
done
|
||||||
|
|
||||||
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
||||||
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
|
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VPN Proxy Gateway</title>
|
<title>VPN</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ TPROXY_PORT=7895
|
|||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
|
||||||
|
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
|
||||||
|
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
|
||||||
|
TPROXY_BYPASS_SOURCE_CIDRS=
|
||||||
ROUTING_RU_DIRECT=true
|
ROUTING_RU_DIRECT=true
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
|||||||
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||||
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
COMPOSE_FILE="docker-compose.client.yml"
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
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"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[vpn-proxy-client] %s\n' "$*"
|
printf '[vpn-proxy-client] %s\n' "$*"
|
||||||
@@ -19,12 +23,194 @@ need() {
|
|||||||
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_valid_port() {
|
||||||
|
case "$1" in
|
||||||
|
''|*[!0-9]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_proxy_port() {
|
||||||
|
local value=""
|
||||||
|
if [ -n "$REQUESTED_PROXY_PORT" ]; then
|
||||||
|
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$REQUESTED_PROXY_PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r /dev/tty ]; then
|
||||||
|
while true; do
|
||||||
|
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$DEFAULT_PROXY_PORT}"
|
||||||
|
if is_valid_port "$value"; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$DEFAULT_PROXY_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
published_port_conflicts() {
|
||||||
|
local port="$1"
|
||||||
|
local line
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||||
|
*) printf '%s\n' "$line" ;;
|
||||||
|
esac
|
||||||
|
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_port_conflicts() {
|
||||||
|
published_port_conflicts "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_proxy_port_available() {
|
||||||
|
local port="$1"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(proxy_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_port_available() {
|
||||||
|
local label="$1"
|
||||||
|
local port="$2"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(published_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
|
||||||
|
"$label" "$port" "$conflicts" >&2
|
||||||
|
die "choose another ${label} port or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
first_free_port() {
|
||||||
|
local start="$1"
|
||||||
|
local port
|
||||||
|
|
||||||
|
for port in $(seq "$start" 65535); do
|
||||||
|
if [ -z "$(published_port_conflicts "$port")" ]; then
|
||||||
|
printf '%s\n' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_ui_port() {
|
||||||
|
local value="$1"
|
||||||
|
local suggested
|
||||||
|
|
||||||
|
if ! is_valid_port "$value"; then
|
||||||
|
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
|
||||||
|
assert_single_port_available "UI" "$value"
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggested="$(first_free_port "$((value + 1))" || true)"
|
||||||
|
suggested="${suggested:-3457}"
|
||||||
|
while true; do
|
||||||
|
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$suggested}"
|
||||||
|
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_ui_outside_proxy_range() {
|
||||||
|
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
|
||||||
|
die "UI port ${UI_PORT} overlaps proxy port"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_client_ui() {
|
||||||
|
local ui_port="${UI_PORT:-3456}"
|
||||||
|
local ui_url="http://127.0.0.1:${ui_port}/api/state"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
|
printf '[vpn-proxy-client] docker compose status:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
|
||||||
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
if [ -f .env ] && grep -q "^${key}=" .env; then
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print key "=" value; next }
|
||||||
|
{ print }
|
||||||
|
' .env > "$tmp"
|
||||||
|
else
|
||||||
|
[ -f .env ] && cat .env > "$tmp"
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$tmp" .env
|
||||||
|
}
|
||||||
|
|
||||||
|
get_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[ -f .env ] || return 0
|
||||||
|
awk -v key="$key" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
|
||||||
|
' .env
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
die "this installer is intended for macOS"
|
die "this installer is intended for macOS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
need git
|
need git
|
||||||
need docker
|
need docker
|
||||||
|
need curl
|
||||||
|
|
||||||
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
@@ -46,18 +232,38 @@ if [[ ! -f .env && -f .env.example ]]; then
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PROXY_PORT="$(ask_proxy_port)"
|
||||||
|
assert_proxy_port_available "$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")"
|
||||||
|
assert_ui_outside_proxy_range
|
||||||
|
|
||||||
|
set_env_value APP_MODE client
|
||||||
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT"
|
||||||
|
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}"
|
||||||
|
|
||||||
log "building and starting Docker client"
|
log "building and starting Docker client"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
wait_for_client_ui
|
||||||
|
|
||||||
cat <<'EOF'
|
cat <<EOF
|
||||||
|
|
||||||
VPN Proxy Client is running.
|
VPN Proxy Client is running.
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
http://127.0.0.1:3456
|
http://127.0.0.1:${UI_PORT}
|
||||||
|
|
||||||
Proxy:
|
Proxy:
|
||||||
HTTP/SOCKS5 127.0.0.1:8080
|
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
|
||||||
|
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
|
||||||
|
|
||||||
Useful commands:
|
Useful commands:
|
||||||
cd ~/.vpn-proxy-client
|
cd ~/.vpn-proxy-client
|
||||||
@@ -66,9 +272,9 @@ Useful commands:
|
|||||||
docker compose -f docker-compose.client.yml down
|
docker compose -f docker-compose.client.yml down
|
||||||
|
|
||||||
Optional macOS system proxy example:
|
Optional macOS system proxy example:
|
||||||
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8080
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
|
||||||
Disable later:
|
Disable later:
|
||||||
networksetup -setwebproxystate Wi-Fi off
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
|||||||
@@ -4,8 +4,29 @@ import { settings } from "./config.js";
|
|||||||
|
|
||||||
const DEFAULT_CLIENT_SETTINGS = {
|
const DEFAULT_CLIENT_SETTINGS = {
|
||||||
homeBypassEnabled: false,
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
|
sharedProxyControlUrl: "",
|
||||||
|
sharedProxy: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeProxyPort(value, fallback = settings.proxyPort) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
const min = Number.isInteger(settings.clientProxyPortStart)
|
||||||
|
? settings.clientProxyPortStart
|
||||||
|
: settings.proxyPort;
|
||||||
|
const max = Number.isInteger(settings.clientProxyPortEnd)
|
||||||
|
? settings.clientProxyPortEnd
|
||||||
|
: min;
|
||||||
|
const fallbackPort =
|
||||||
|
Number.isInteger(fallback) && fallback >= min && fallback <= max
|
||||||
|
? fallback
|
||||||
|
: min;
|
||||||
|
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||||
|
return fallbackPort;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(filePath, fallback) {
|
function readJson(filePath, fallback) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) return fallback;
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
@@ -20,15 +41,52 @@ function writeJson(filePath, value) {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) return "";
|
||||||
|
url.hash = "";
|
||||||
|
url.search = "";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSharedProxy(value) {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const host = String(value.host || "").trim();
|
||||||
|
const port = Number.parseInt(value.port, 10);
|
||||||
|
const protocol = value.protocol === "http" ? "http" : "socks5";
|
||||||
|
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
checkedAt: value.checkedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeClientSettings(input = {}) {
|
export function normalizeClientSettings(input = {}) {
|
||||||
|
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||||
|
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||||
return {
|
return {
|
||||||
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||||
|
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||||
|
sharedProxyEnabled,
|
||||||
|
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||||
|
sharedProxy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readClientSettings() {
|
export function readClientSettings() {
|
||||||
return normalizeClientSettings({
|
return normalizeClientSettings({
|
||||||
...DEFAULT_CLIENT_SETTINGS,
|
...DEFAULT_CLIENT_SETTINGS,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
...readJson(settings.clientSettingsPath, {}),
|
...readJson(settings.clientSettingsPath, {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
const parsePort = (value, fallback) => {
|
||||||
|
const 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 = {
|
export const settings = {
|
||||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
port: Number(process.env.PORT || 3456),
|
port: parsePort(process.env.PORT, 3456),
|
||||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
proxyPort,
|
||||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
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",
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || "/app/dist",
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
@@ -20,6 +45,7 @@ export const settings = {
|
|||||||
devicesPath: path.join(dataDir, "devices.json"),
|
devicesPath: path.join(dataDir, "devices.json"),
|
||||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
hwidPath: path.join(dataDir, "hwid"),
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { settings } from "./config.js";
|
import { settings } from "./config.js";
|
||||||
|
|
||||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
|
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_MODES = new Set(["direct", "vpn", "block"]);
|
||||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||||
export const DEFAULT_PROXY_MODE = "vpn";
|
export const DEFAULT_PROXY_MODE = "vpn";
|
||||||
@@ -27,7 +27,6 @@ function writeJson(filePath, value) {
|
|||||||
|
|
||||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||||
const value = String(mode || "").trim().toLowerCase();
|
const value = String(mode || "").trim().toLowerCase();
|
||||||
if (value === "bypass") return "direct";
|
|
||||||
return DEVICE_MODES.has(value) ? value : fallback;
|
return DEVICE_MODES.has(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { settings } from "./config.js";
|
import { settings } from "./config.js";
|
||||||
import { fetchSubscription } from "./subscription.js";
|
import { fetchSubscription, fetchSubscriptionInfo } from "./subscription.js";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import {
|
import {
|
||||||
buildGatewayConfig,
|
buildGatewayConfig,
|
||||||
@@ -21,6 +21,14 @@ import {
|
|||||||
readClientSettings,
|
readClientSettings,
|
||||||
writeClientSettings,
|
writeClientSettings,
|
||||||
} from "./clientSettings.js";
|
} from "./clientSettings.js";
|
||||||
|
import {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} from "./sharedProxy.js";
|
||||||
|
import {
|
||||||
|
sourceBypassCidrs,
|
||||||
|
syncTproxySourceBypass,
|
||||||
|
} from "./tproxySourceBypass.js";
|
||||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||||
import { tcpPing, resolveHost } from "./ping.js";
|
import { tcpPing, resolveHost } from "./ping.js";
|
||||||
|
|
||||||
@@ -598,15 +606,21 @@ function publicState() {
|
|||||||
const state = readJson(settings.statePath, {});
|
const state = readJson(settings.statePath, {});
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
const customRules = readJson(settings.customRulesPath, []);
|
||||||
const deviceProfiles = readDeviceProfiles();
|
const deviceProfiles = readDeviceProfiles();
|
||||||
const { subscriptionUrl, ...rest } = state;
|
const clientSettings = readClientSettings();
|
||||||
|
const { subscriptionUrl, servers = [], ...rest } = state;
|
||||||
return {
|
return {
|
||||||
mode: settings.appMode,
|
mode: settings.appMode,
|
||||||
port: settings.port,
|
port: settings.port,
|
||||||
proxyPort: settings.proxyPort,
|
proxyPort:
|
||||||
|
settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort,
|
||||||
|
clientProxyPortRange: {
|
||||||
|
start: settings.clientProxyPortStart,
|
||||||
|
end: settings.clientProxyPortEnd,
|
||||||
|
},
|
||||||
proxyBindIp: settings.bindIp,
|
proxyBindIp: settings.bindIp,
|
||||||
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
||||||
routingRuDirect: settings.routingRuDirect,
|
routingRuDirect: settings.routingRuDirect,
|
||||||
clientSettings: readClientSettings(),
|
clientSettings,
|
||||||
configExists: fs.existsSync(settings.configPath),
|
configExists: fs.existsSync(settings.configPath),
|
||||||
singboxRunning: Boolean(singboxProcess),
|
singboxRunning: Boolean(singboxProcess),
|
||||||
singboxStartedAt,
|
singboxStartedAt,
|
||||||
@@ -624,7 +638,12 @@ function publicState() {
|
|||||||
directBypassCount,
|
directBypassCount,
|
||||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||||
directBypassAvailable: IPSET_AVAILABLE,
|
directBypassAvailable: IPSET_AVAILABLE,
|
||||||
|
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
|
||||||
...rest,
|
...rest,
|
||||||
|
servers: servers.map((server) => ({
|
||||||
|
...server,
|
||||||
|
tag: String(server.tag || '').trim(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +735,36 @@ async function applySelectedServer(selectedTag) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyClientSharedProxy() {
|
||||||
|
const clientSettings = readClientSettings();
|
||||||
|
if (!clientSettings.sharedProxyEnabled || !clientSettings.sharedProxy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = buildGatewayConfig(
|
||||||
|
{ outbounds: [], customRules: [] },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
writeSingboxConfig(generated);
|
||||||
|
await startSingbox();
|
||||||
|
pushLog(
|
||||||
|
"info",
|
||||||
|
`Mac client uses shared gateway proxy ${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyClientDirectProxy() {
|
||||||
|
const generated = buildGatewayConfig(
|
||||||
|
{ outbounds: [], customRules: [] },
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
writeSingboxConfig(generated);
|
||||||
|
await startSingbox();
|
||||||
|
pushLog("info", "Mac client routes local proxy directly");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogsStream(req, res) {
|
function handleLogsStream(req, res) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
@@ -750,6 +799,20 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, publicState());
|
return sendJson(res, 200, publicState());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/api/shared-proxy") {
|
||||||
|
return sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
buildSharedProxyInfo({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
running: Boolean(singboxProcess),
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: settings.sharedProxyHost,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && req.url === "/api/config") {
|
if (req.method === "GET" && req.url === "/api/config") {
|
||||||
const config = readSingboxConfig();
|
const config = readSingboxConfig();
|
||||||
return sendJson(res, 200, { success: true, config });
|
return sendJson(res, 200, { success: true, config });
|
||||||
@@ -916,6 +979,13 @@ async function handleApi(req, res) {
|
|||||||
devices: body.devices,
|
devices: body.devices,
|
||||||
};
|
};
|
||||||
const profiles = writeDeviceProfiles(input);
|
const profiles = writeDeviceProfiles(input);
|
||||||
|
const sourceBypassResult = syncTproxySourceBypass(profiles);
|
||||||
|
if (!sourceBypassResult.success) {
|
||||||
|
pushLog(
|
||||||
|
"warning",
|
||||||
|
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const prevState = readJson(settings.statePath, {});
|
const prevState = readJson(settings.statePath, {});
|
||||||
const devicesUpdatedAt = new Date().toISOString();
|
const devicesUpdatedAt = new Date().toISOString();
|
||||||
writeJson(settings.statePath, {
|
writeJson(settings.statePath, {
|
||||||
@@ -925,6 +995,8 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
...profiles,
|
...profiles,
|
||||||
|
sourceBypassCidrs: sourceBypassCidrs(profiles),
|
||||||
|
sourceBypassResult,
|
||||||
devicesUpdatedAt,
|
devicesUpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -941,12 +1013,48 @@ async function handleApi(req, res) {
|
|||||||
const clientSettings = writeClientSettings(body.clientSettings || body);
|
const clientSettings = writeClientSettings(body.clientSettings || body);
|
||||||
const prevState = readJson(settings.statePath, {});
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
|
||||||
if (
|
if (settings.appMode === "client") {
|
||||||
settings.appMode === "client" &&
|
if (clientSettings.sharedProxyEnabled) {
|
||||||
prevState.selectedTag &&
|
await applyClientSharedProxy();
|
||||||
readJson(settings.subscriptionCachePath, null)?.config
|
} else if (clientSettings.homeBypassEnabled) {
|
||||||
) {
|
await applyClientDirectProxy();
|
||||||
await applySelectedServer(prevState.selectedTag);
|
} else if (
|
||||||
|
prevState.selectedTag &&
|
||||||
|
readJson(settings.subscriptionCachePath, null)?.config
|
||||||
|
) {
|
||||||
|
await applySelectedServer(prevState.selectedTag);
|
||||||
|
} else {
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
clientSettings,
|
||||||
|
singboxRunning: Boolean(singboxProcess),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/api/client-settings/shared-proxy/check") {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const url = String(body.url || "").trim();
|
||||||
|
if (!url) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
success: false,
|
||||||
|
error: "Укажите адрес gateway",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = await checkSharedProxyGateway(url);
|
||||||
|
const clientSettings = writeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
...patch,
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings.appMode === "client") {
|
||||||
|
await applyClientSharedProxy();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
@@ -1220,7 +1328,7 @@ async function handleApi(req, res) {
|
|||||||
servers.map(async (server) => {
|
servers.map(async (server) => {
|
||||||
const ping = await tcpPing(server.server, server.server_port, 3000);
|
const ping = await tcpPing(server.server, server.server_port, 3000);
|
||||||
return {
|
return {
|
||||||
tag: server.tag,
|
tag: String(server.tag || '').trim(),
|
||||||
...ping,
|
...ping,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -1316,6 +1424,19 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/api/subscription/refresh-info") {
|
||||||
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
if (!prevState.subscriptionUrl) {
|
||||||
|
return sendJson(res, 400, { success: false, error: "Подписка не настроена" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await fetchSubscriptionInfo(prevState.subscriptionUrl);
|
||||||
|
writeJson(settings.statePath, { ...prevState, ...info });
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||||
|
return sendJson(res, 200, { success: true, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "DELETE" && req.url === "/api/subscription") {
|
if (req.method === "DELETE" && req.url === "/api/subscription") {
|
||||||
if (fs.existsSync(settings.subscriptionCachePath))
|
if (fs.existsSync(settings.subscriptionCachePath))
|
||||||
fs.rmSync(settings.subscriptionCachePath);
|
fs.rmSync(settings.subscriptionCachePath);
|
||||||
@@ -1342,6 +1463,14 @@ async function handleApi(req, res) {
|
|||||||
error: "selectedTag обязателен",
|
error: "selectedTag обязателен",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (settings.appMode === "client") {
|
||||||
|
writeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await applySelectedServer(selectedTag);
|
await applySelectedServer(selectedTag);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1443,6 +1572,14 @@ process.on("SIGINT", async () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
|
||||||
|
if (!sourceBypassStartup.success) {
|
||||||
|
pushLog(
|
||||||
|
"warning",
|
||||||
|
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// При старте пробуем подхватить уже запущенный sing-box
|
// При старте пробуем подхватить уже запущенный sing-box
|
||||||
const existingPid = readSingboxPid();
|
const existingPid = readSingboxPid();
|
||||||
if (existingPid && isPidAlive(existingPid)) {
|
if (existingPid && isPidAlive(existingPid)) {
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ export function matchRoute(target, customRules, options = {}) {
|
|||||||
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
inbound === TPROXY_INBOUND &&
|
||||||
|
matchedDevice &&
|
||||||
|
matchedDevice.mode === "bypass"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
matched: "kernel-bypass",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleId: matchedDevice.id,
|
||||||
|
ruleName: `${matchedDevice.name} -> bypass TProxy`,
|
||||||
|
outbound: "direct",
|
||||||
|
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 1. private IP → direct
|
// 1. private IP → direct
|
||||||
if (target.ip && isPrivateIp(target.ip)) {
|
if (target.ip && isPrivateIp(target.ip)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
94
src/server/sharedProxy.js
Normal file
94
src/server/sharedProxy.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
function normalizeControlUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||||
|
const url = new URL(withProtocol);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
throw new Error("Gateway URL must use http or https");
|
||||||
|
}
|
||||||
|
url.hash = "";
|
||||||
|
url.search = "";
|
||||||
|
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyHostFromHeader(hostHeader) {
|
||||||
|
const raw = String(hostHeader || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
const end = raw.indexOf("]");
|
||||||
|
return end > 0 ? raw.slice(1, end) : "";
|
||||||
|
}
|
||||||
|
return raw.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyInfo(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== "object") return null;
|
||||||
|
const host = String(proxy.host || "").trim();
|
||||||
|
const port = Number.parseInt(proxy.port, 10);
|
||||||
|
const protocol = proxy.protocol === "http" ? "http" : "socks5";
|
||||||
|
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { host, port, protocol };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSharedProxyInfo({
|
||||||
|
appMode,
|
||||||
|
proxyPort,
|
||||||
|
running,
|
||||||
|
hostHeader,
|
||||||
|
sharedProxyHost,
|
||||||
|
}) {
|
||||||
|
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||||
|
const port = Number.parseInt(proxyPort, 10);
|
||||||
|
const available =
|
||||||
|
appMode === "gateway" &&
|
||||||
|
Boolean(running) &&
|
||||||
|
host &&
|
||||||
|
Number.isInteger(port) &&
|
||||||
|
port > 0 &&
|
||||||
|
port <= 65535;
|
||||||
|
|
||||||
|
const proxy = available
|
||||||
|
? {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: `http://${host}:${port}`,
|
||||||
|
socksUrl: `socks5://${host}:${port}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
available,
|
||||||
|
mode: appMode,
|
||||||
|
proxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
|
||||||
|
const baseUrl = normalizeControlUrl(controlUrl);
|
||||||
|
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `Gateway returned ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!data.available) {
|
||||||
|
throw new Error("Gateway shared proxy is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProxy = normalizeProxyInfo(data.proxy);
|
||||||
|
if (!sharedProxy) {
|
||||||
|
throw new Error("Gateway returned invalid shared proxy settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxyControlUrl: baseUrl,
|
||||||
|
sharedProxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -242,28 +242,57 @@ function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
|||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sharedProxyOutbound(sharedProxy) {
|
||||||
|
if (!sharedProxy?.host || !sharedProxy?.port) return null;
|
||||||
|
if (sharedProxy.protocol === "http") {
|
||||||
|
return {
|
||||||
|
type: "http",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: sharedProxy.host,
|
||||||
|
server_port: sharedProxy.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "socks",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: sharedProxy.host,
|
||||||
|
server_port: sharedProxy.port,
|
||||||
|
version: "5",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildGatewayConfig(
|
export function buildGatewayConfig(
|
||||||
subscriptionConfig,
|
subscriptionConfig,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
{ bypassAll = false } = {},
|
{ bypassAll = false } = {},
|
||||||
) {
|
) {
|
||||||
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
|
||||||
if (!selectedOutbound) {
|
|
||||||
throw new Error(`Outbound не найден: ${selectedTag}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vpnOutbound = clone(selectedOutbound);
|
|
||||||
if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
|
||||||
if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
|
|
||||||
vpnOutbound.packet_encoding = "xudp";
|
|
||||||
}
|
|
||||||
|
|
||||||
const customRuleSets = readCustomRuleSets();
|
const customRuleSets = readCustomRuleSets();
|
||||||
const clientMode = settings.appMode === "client";
|
const clientMode = settings.appMode === "client";
|
||||||
const clientSettings = clientMode ? readClientSettings() : null;
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
const clientOutbound = clientSettings?.homeBypassEnabled
|
const sharedOutbound =
|
||||||
? "direct"
|
clientMode && clientSettings?.sharedProxyEnabled
|
||||||
: vpnOutbound.tag;
|
? sharedProxyOutbound(clientSettings.sharedProxy)
|
||||||
|
: null;
|
||||||
|
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
|
||||||
|
const selectedOutbound = sharedOutbound
|
||||||
|
? null
|
||||||
|
: findOutbound(subscriptionConfig, selectedTag);
|
||||||
|
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
|
||||||
|
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
|
||||||
|
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
||||||
|
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
|
||||||
|
vpnOutbound.packet_encoding = "xudp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOutbound = sharedOutbound
|
||||||
|
? sharedOutbound.tag
|
||||||
|
: clientSettings?.homeBypassEnabled
|
||||||
|
? "direct"
|
||||||
|
: vpnOutbound.tag;
|
||||||
|
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||||
const inbounds = [
|
const inbounds = [
|
||||||
...(clientMode
|
...(clientMode
|
||||||
@@ -282,7 +311,7 @@ export function buildGatewayConfig(
|
|||||||
type: "mixed",
|
type: "mixed",
|
||||||
tag: "mixed-in",
|
tag: "mixed-in",
|
||||||
listen: settings.bindIp,
|
listen: settings.bindIp,
|
||||||
listen_port: settings.proxyPort,
|
listen_port: mixedProxyPort,
|
||||||
sniff: true,
|
sniff: true,
|
||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
},
|
},
|
||||||
@@ -304,7 +333,7 @@ export function buildGatewayConfig(
|
|||||||
},
|
},
|
||||||
inbounds,
|
inbounds,
|
||||||
outbounds: [
|
outbounds: [
|
||||||
vpnOutbound,
|
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||||
{ type: "direct", tag: "direct" },
|
{ type: "direct", tag: "direct" },
|
||||||
{ type: "block", tag: "block" },
|
{ type: "block", tag: "block" },
|
||||||
],
|
],
|
||||||
@@ -318,7 +347,7 @@ export function buildGatewayConfig(
|
|||||||
includeTransparent: !clientMode,
|
includeTransparent: !clientMode,
|
||||||
}),
|
}),
|
||||||
final: "direct",
|
final: "direct",
|
||||||
auto_detect_interface: true,
|
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function parseSubscriptionBody(body) {
|
|||||||
const servers = outbounds
|
const servers = outbounds
|
||||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||||
.map((outbound) => ({
|
.map((outbound) => ({
|
||||||
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||||
type: outbound.type,
|
type: outbound.type,
|
||||||
server: outbound.server || 'unknown',
|
server: outbound.server || 'unknown',
|
||||||
server_port: outbound.server_port || 443,
|
server_port: outbound.server_port || 443,
|
||||||
@@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) {
|
|||||||
return { config: parsedConfig, servers };
|
return { config: parsedConfig, servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscription(url) {
|
async function requestSubscription(url) {
|
||||||
let parsedUrl;
|
let parsedUrl;
|
||||||
try {
|
try {
|
||||||
parsedUrl = new URL(url);
|
parsedUrl = new URL(url);
|
||||||
@@ -157,6 +157,21 @@ export async function fetchSubscription(url) {
|
|||||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscriptionInfo(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
await response.body?.cancel();
|
||||||
|
return {
|
||||||
|
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||||
const parsed = parseSubscriptionBody(body);
|
const parsed = parseSubscriptionBody(body);
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -33,9 +33,6 @@ function App() {
|
|||||||
proxyDefaultMode: 'vpn',
|
proxyDefaultMode: 'vpn',
|
||||||
devices: [],
|
devices: [],
|
||||||
});
|
});
|
||||||
const [clientSettings, setClientSettings] = useState({
|
|
||||||
homeBypassEnabled: false,
|
|
||||||
});
|
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
const [selectedTag, setSelectedTag] = useState('');
|
||||||
const [pendingTag, setPendingTag] = useState('');
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -81,7 +78,6 @@ function App() {
|
|||||||
proxyDefaultMode: 'vpn',
|
proxyDefaultMode: 'vpn',
|
||||||
devices: data.devices || [],
|
devices: data.devices || [],
|
||||||
});
|
});
|
||||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
|
||||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||||
}
|
}
|
||||||
@@ -108,7 +104,9 @@ function App() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const result = await fn();
|
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;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -124,14 +122,23 @@ function App() {
|
|||||||
return withBusy('Подписка обновлена', async () => {
|
return withBusy('Подписка обновлена', async () => {
|
||||||
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
if (!selectedTag && data.servers?.length) {
|
if (data.servers?.length) {
|
||||||
setSelectedTag(data.servers[0].tag);
|
const nextTag = data.servers.some((server) => server.tag === selectedTag)
|
||||||
setPendingTag(data.servers[0].tag);
|
? selectedTag
|
||||||
|
: data.servers[0].tag;
|
||||||
|
setSelectedTag(nextTag);
|
||||||
|
setPendingTag(nextTag);
|
||||||
}
|
}
|
||||||
await loadState();
|
await loadState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSubscriptionInfo() {
|
||||||
|
const data = await api.subscription.refreshInfo();
|
||||||
|
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function forgetSubscription() {
|
async function forgetSubscription() {
|
||||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||||
return withBusy('Подписка удалена', async () => {
|
return withBusy('Подписка удалена', async () => {
|
||||||
@@ -157,7 +164,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
setApplyStatus('idle');
|
setApplyStatus('idle');
|
||||||
|
|
||||||
if (previous && previous !== target) {
|
if (state?.mode !== 'client' && previous && previous !== target) {
|
||||||
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
|
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
|
||||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||||
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
|
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
|
||||||
@@ -179,8 +186,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === sing-box control ===
|
// === sing-box control ===
|
||||||
async function stopSingbox() {
|
async function stopSingbox(confirmFirst = true) {
|
||||||
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||||
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
||||||
}
|
}
|
||||||
async function restartSingbox() {
|
async function restartSingbox() {
|
||||||
@@ -223,7 +230,19 @@ function App() {
|
|||||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||||
devices: data.devices || [],
|
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) {
|
} catch (err) {
|
||||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||||
}
|
}
|
||||||
@@ -265,14 +284,6 @@ function App() {
|
|||||||
saveDevicesConfig(nextConfig);
|
saveDevicesConfig(nextConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveClientSettings(nextSettings) {
|
|
||||||
return withBusy(null, async () => {
|
|
||||||
const data = await api.clientSettings.save(nextSettings);
|
|
||||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
|
||||||
await loadState();
|
|
||||||
}, { quiet: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Rules CRUD ===
|
// === Rules CRUD ===
|
||||||
function emptyRule() {
|
function emptyRule() {
|
||||||
return {
|
return {
|
||||||
@@ -389,16 +400,20 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Render ===
|
// === Render ===
|
||||||
|
if (!state) return <div className="app-loading">VPN</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className={`app${isClientMode ? ' client-app' : ''}`}>
|
||||||
<Topbar
|
{!isClientMode && (
|
||||||
state={state}
|
<Topbar
|
||||||
status={status}
|
state={state}
|
||||||
activeServer={activeServer}
|
status={status}
|
||||||
dirty={dirty}
|
activeServer={activeServer}
|
||||||
onRestart={restartSingbox}
|
dirty={dirty}
|
||||||
onTryApply={rollback}
|
onRestart={restartSingbox}
|
||||||
/>
|
onTryApply={rollback}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||||
@@ -416,12 +431,11 @@ function App() {
|
|||||||
servers={servers}
|
servers={servers}
|
||||||
pendingTag={pendingTag}
|
pendingTag={pendingTag}
|
||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
clientSettings={clientSettings}
|
|
||||||
onSaveClientSettings={saveClientSettings}
|
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
|
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||||
onApply={applyServer}
|
onApply={applyServer}
|
||||||
onRestart={restartSingbox}
|
onRestart={restartSingbox}
|
||||||
onStop={stopSingbox}
|
onStop={() => stopSingbox(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OverviewPage
|
<OverviewPage
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
clientSettings: {
|
|
||||||
get: () => request("/api/client-settings"),
|
|
||||||
save: (clientSettings) =>
|
|
||||||
request("/api/client-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ clientSettings }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
ruleSets: {
|
ruleSets: {
|
||||||
get: () => request("/api/rule-sets"),
|
get: () => request("/api/rule-sets"),
|
||||||
save: (ruleSets) =>
|
save: (ruleSets) =>
|
||||||
@@ -74,6 +65,7 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
}),
|
}),
|
||||||
|
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
|
||||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,305 +1,375 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { flagFor } from '../utils/country.js';
|
import { api } from '../api.js';
|
||||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
import {
|
||||||
|
connectionAction,
|
||||||
function CopyField({ label, value }) {
|
formatConnectionDuration,
|
||||||
const [copied, setCopied] = useState(false);
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
async function copy() {
|
subscriptionDaysLeft,
|
||||||
await navigator.clipboard.writeText(value);
|
subscriptionUsage,
|
||||||
setCopied(true);
|
} from '../utils/clientControls.js';
|
||||||
setTimeout(() => setCopied(false), 1400);
|
import { formatBytes } from '../utils/format.js';
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="copy-field">
|
|
||||||
<div>
|
|
||||||
<small className="muted">{label}</small>
|
|
||||||
<div className="text-mono">{value}</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-secondary sm" onClick={copy}>
|
|
||||||
{copied ? 'Скопировано' : 'Копировать'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientHero({ state, status, activeServer }) {
|
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
|
||||||
const cfg = {
|
|
||||||
running: {
|
|
||||||
title: 'Локальный proxy работает',
|
|
||||||
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
|
||||||
badge: 'Готов',
|
|
||||||
kind: 'success',
|
|
||||||
},
|
|
||||||
applying: {
|
|
||||||
title: 'Применяем сервер',
|
|
||||||
hint: 'sing-box перезапускается',
|
|
||||||
badge: 'Применяем',
|
|
||||||
kind: 'warning',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
title: 'Нужна проверка',
|
|
||||||
hint: 'Откройте логи и config',
|
|
||||||
badge: 'Ошибка',
|
|
||||||
kind: 'danger',
|
|
||||||
},
|
|
||||||
stopped: {
|
|
||||||
title: 'Proxy остановлен',
|
|
||||||
hint: 'Конфиг есть, sing-box не запущен',
|
|
||||||
badge: 'Остановлен',
|
|
||||||
kind: 'warning',
|
|
||||||
},
|
|
||||||
no_config: {
|
|
||||||
title: 'Proxy ещё не настроен',
|
|
||||||
hint: 'Загрузите подписку и выберите сервер',
|
|
||||||
badge: 'Не настроен',
|
|
||||||
kind: 'neutral',
|
|
||||||
},
|
|
||||||
}[status] || {};
|
|
||||||
const view = homeBypass
|
|
||||||
? {
|
|
||||||
...cfg,
|
|
||||||
title: 'Домашний режим: VPN выключен',
|
|
||||||
hint: 'Локальный proxy работает напрямую',
|
|
||||||
badge: 'Напрямую',
|
|
||||||
kind: 'info',
|
|
||||||
}
|
|
||||||
: cfg;
|
|
||||||
|
|
||||||
const userInfo = state?.userInfo;
|
|
||||||
const traffic = userInfo
|
|
||||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
|
||||||
: 'нет данных';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="client-hero">
|
|
||||||
<div className="client-hero-main">
|
|
||||||
<span className={`badge ${view.kind}`}>{view.badge}</span>
|
|
||||||
<h1>{view.title}</h1>
|
|
||||||
<p>{view.hint}</p>
|
|
||||||
</div>
|
|
||||||
<div className="client-hero-meta">
|
|
||||||
<div>
|
|
||||||
<small className="muted">Активный сервер</small>
|
|
||||||
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small className="muted">Трафик</small>
|
|
||||||
<strong>{traffic}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<small className="muted">Применено</small>
|
|
||||||
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientSetup({
|
|
||||||
state,
|
|
||||||
servers,
|
|
||||||
subscriptionUrl,
|
|
||||||
setSubscriptionUrl,
|
|
||||||
pendingTag,
|
|
||||||
setPendingTag,
|
|
||||||
busy,
|
|
||||||
onFetchSubscription,
|
|
||||||
onApply,
|
|
||||||
}) {
|
|
||||||
const selected = pendingTag || state?.selectedTag || '';
|
|
||||||
const canApply = selected && selected !== state?.selectedTag;
|
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card client-setup">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Подключение</h2>
|
|
||||||
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
|
||||||
<div className="subscription-input">
|
|
||||||
<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-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
|
||||||
Загрузить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">VPN-сервер</label>
|
|
||||||
<div className="subscription-input">
|
|
||||||
<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-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<small className="field-hint">
|
|
||||||
{homeBypass
|
|
||||||
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
|
||||||
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProxyCard({ state }) {
|
|
||||||
const port = state?.proxyPort || 8080;
|
|
||||||
const urls = useMemo(() => ({
|
|
||||||
http: `http://127.0.0.1:${port}`,
|
|
||||||
socks: `socks5://127.0.0.1:${port}`,
|
|
||||||
}), [port]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Локальный proxy</h2>
|
|
||||||
<span className="badge info">127.0.0.1:{port}</span>
|
|
||||||
</div>
|
|
||||||
<div className="copy-stack">
|
|
||||||
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
|
||||||
<CopyField label="SOCKS5" value={urls.socks} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeBypassCard({ settings, busy, onSave }) {
|
|
||||||
const enabled = Boolean(settings?.homeBypassEnabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h2>Домашний режим</h2>
|
|
||||||
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
|
|
||||||
{enabled ? 'Напрямую' : 'Через VPN'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="muted">
|
|
||||||
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
|
|
||||||
</p>
|
|
||||||
<label className="switch-row">
|
|
||||||
<span>
|
|
||||||
<strong>Я дома</strong>
|
|
||||||
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientFlow({ state, activeServer }) {
|
|
||||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
|
||||||
const steps = [
|
|
||||||
{ label: 'Mac', value: 'приложения' },
|
|
||||||
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
|
||||||
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
|
|
||||||
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header"><h2>Цепочка подключения</h2></div>
|
|
||||||
<div className="client-flow">
|
|
||||||
{steps.map((step, index) => (
|
|
||||||
<React.Fragment key={step.label}>
|
|
||||||
<div className="flow-node">
|
|
||||||
<small>{step.label}</small>
|
|
||||||
<strong>{step.value}</strong>
|
|
||||||
</div>
|
|
||||||
{index < steps.length - 1 && <span className="flow-arrow">→</span>}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientActions({ state, busy, onRestart, onStop }) {
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-header"><h2>Управление</h2></div>
|
|
||||||
<div className="btn-group">
|
|
||||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
|
|
||||||
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
status,
|
|
||||||
activeServer,
|
|
||||||
busy,
|
busy,
|
||||||
subscriptionUrl,
|
subscriptionUrl,
|
||||||
setSubscriptionUrl,
|
setSubscriptionUrl,
|
||||||
servers,
|
servers,
|
||||||
pendingTag,
|
pendingTag,
|
||||||
setPendingTag,
|
setPendingTag,
|
||||||
clientSettings,
|
|
||||||
onSaveClientSettings,
|
|
||||||
onFetchSubscription,
|
onFetchSubscription,
|
||||||
|
onRefreshSubscriptionInfo,
|
||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
onStop,
|
onStop,
|
||||||
}) {
|
}) {
|
||||||
|
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 [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||||
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
|
const subscriptionInputRef = useRef(null);
|
||||||
|
const subscriptionRef = useRef(null);
|
||||||
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
|
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||||
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
|
const hasUsage = Boolean(
|
||||||
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
if (!connected || !state?.singboxStartedAt) return undefined;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription) return undefined;
|
||||||
|
const closeOnOutsideClick = (event) => {
|
||||||
|
if (subscriptionRef.current?.contains(event.target)) return;
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
}, [editingSubscription, setSubscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state?.hasSubscription) return undefined;
|
||||||
|
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [state?.hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = displayedUsed;
|
||||||
|
const to = usage.used;
|
||||||
|
if (from === to) return undefined;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let frame;
|
||||||
|
const tick = (now) => {
|
||||||
|
const progress = Math.min(1, (now - startedAt) / 900);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 4);
|
||||||
|
setDisplayedUsed(from + (to - from) * eased);
|
||||||
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [usage.used]);
|
||||||
|
|
||||||
|
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 selectServer(tag) {
|
||||||
|
setPendingTag(tag);
|
||||||
|
if (connected && tag) onApply(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(''), 800);
|
||||||
|
} catch {
|
||||||
|
setCopiedProxy('error');
|
||||||
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInfo() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
setRefreshingInfo(true);
|
||||||
|
try {
|
||||||
|
await onRefreshSubscriptionInfo();
|
||||||
|
setUsageUpdated(false);
|
||||||
|
requestAnimationFrame(() => setUsageUpdated(true));
|
||||||
|
setTimeout(() => setUsageUpdated(false), 900);
|
||||||
|
setServersLeaving(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
|
||||||
|
setServerRevealVersion((version) => version + 1);
|
||||||
|
setServersLeaving(false);
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||||||
|
setRefreshingInfo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-stack">
|
<div className="client-shell">
|
||||||
<ClientHero state={state} status={status} activeServer={activeServer} />
|
<main className="client-panel">
|
||||||
<ClientSetup
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
state={state}
|
<button
|
||||||
servers={servers}
|
className="client-power"
|
||||||
subscriptionUrl={subscriptionUrl}
|
type="button"
|
||||||
setSubscriptionUrl={setSubscriptionUrl}
|
role="switch"
|
||||||
pendingTag={pendingTag}
|
aria-checked={connected}
|
||||||
setPendingTag={setPendingTag}
|
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
|
||||||
busy={busy}
|
disabled={busy || (!connected && !canStart)}
|
||||||
onFetchSubscription={onFetchSubscription}
|
onClick={toggleConnection}
|
||||||
onApply={onApply}
|
>
|
||||||
/>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<div className="grid-2">
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
<ProxyCard state={state} />
|
</svg>
|
||||||
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
</button>
|
||||||
</div>
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<div className="grid-2">
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
<ClientActions
|
{connected ? 'VPN включён' : 'VPN выключен'}
|
||||||
state={state}
|
</h2>
|
||||||
busy={busy}
|
<div className="client-state-detail">
|
||||||
onRestart={onRestart}
|
{connected ? (
|
||||||
onStop={onStop}
|
<time key="duration" className="client-duration">
|
||||||
/>
|
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||||
</div>
|
</time>
|
||||||
<ClientFlow state={state} activeServer={activeServer} />
|
) : (
|
||||||
|
<p key="hint">
|
||||||
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="client-proxies" aria-label="Локальный прокси">
|
||||||
|
<span className="client-proxy-label">Адрес</span>
|
||||||
|
<strong className="client-proxy-address">
|
||||||
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
|
</strong>
|
||||||
|
<div>
|
||||||
|
{[
|
||||||
|
['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 className="client-copy-label">{label}</span>
|
||||||
|
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="client-form">
|
||||||
|
<div
|
||||||
|
ref={subscriptionRef}
|
||||||
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="client-subscription-summary"
|
||||||
|
aria-hidden={editingSubscription}
|
||||||
|
inert={editingSubscription ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-subscription-heading">
|
||||||
|
<span>Ваша подписка</span>
|
||||||
|
<button
|
||||||
|
className="client-subscription-refresh"
|
||||||
|
type="button"
|
||||||
|
aria-label="Обновить статистику подписки"
|
||||||
|
title="Обновить статистику"
|
||||||
|
disabled={refreshingInfo}
|
||||||
|
onClick={refreshInfo}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="client-subscription-domain-button"
|
||||||
|
type="button"
|
||||||
|
tabIndex={editingSubscription ? -1 : 0}
|
||||||
|
onClick={() => setEditingSubscription(true)}
|
||||||
|
>
|
||||||
|
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="client-subscription-edit"
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden={!editingSubscription}
|
||||||
|
inert={!editingSubscription ? true : undefined}
|
||||||
|
onSubmit={submitSubscription}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={subscriptionInputRef}
|
||||||
|
id="subscription-url"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
tabIndex={editingSubscription ? 0 : -1}
|
||||||
|
aria-label="Ссылка подписки"
|
||||||
|
placeholder="Вставьте ссылку подписки"
|
||||||
|
className={subscriptionUrl ? 'has-value' : ''}
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape' && state?.hasSubscription) {
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{subscriptionUrl && (
|
||||||
|
<span className="client-subscription-domain">
|
||||||
|
{subscriptionDomain(subscriptionUrl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionUrl.trim() && (
|
||||||
|
<button type="submit" aria-label="Сохранить подписку" disabled={busy}>✓</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasUsage && (
|
||||||
|
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||||
|
<span>Использовано</span>
|
||||||
|
<strong>
|
||||||
|
{formatBytes(displayedUsed)}
|
||||||
|
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||||
|
</strong>
|
||||||
|
{usage.percent !== null && (
|
||||||
|
<div
|
||||||
|
className="client-usage-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Использованный трафик"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(usage.percent)}
|
||||||
|
>
|
||||||
|
<i style={{ width: `${usage.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-usage-details">
|
||||||
|
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||||
|
<span>
|
||||||
|
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||||
|
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="client-servers" aria-label="Серверы">
|
||||||
|
<div
|
||||||
|
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||||||
|
key={`${serverKey}:${serverRevealVersion}`}
|
||||||
|
>
|
||||||
|
{servers.map((server, index) => {
|
||||||
|
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'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key={server.tag}
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={selected}
|
||||||
|
style={{ '--server-index': index }}
|
||||||
|
onClick={() => selectServer(server.tag)}
|
||||||
|
>
|
||||||
|
<strong>{server.tag}</strong>
|
||||||
|
<small className={pingClass}>{pingText}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const OUTBOUND_KIND = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEVICE_MODES = {
|
const DEVICE_MODES = {
|
||||||
|
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
|
||||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||||
@@ -28,6 +29,7 @@ const DEVICE_MODES = {
|
|||||||
function DeviceModeSelect({ value, onChange }) {
|
function DeviceModeSelect({ value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="bypass">bypass TProxy</option>
|
||||||
<option value="direct">direct</option>
|
<option value="direct">direct</option>
|
||||||
<option value="vpn">VPN</option>
|
<option value="vpn">VPN</option>
|
||||||
<option value="rules">default</option>
|
<option value="rules">default</option>
|
||||||
@@ -46,7 +48,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Устройства</h2>
|
<h2>Устройства</h2>
|
||||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы — fallback после global rules.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="btn-group">
|
<div className="btn-group">
|
||||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ html, body, #root {
|
|||||||
height: 100%;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
@@ -118,6 +128,9 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
.app-body.client-mode {
|
.app-body.client-mode {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 0%, rgba(142, 212, 255, 0.08), transparent 28rem),
|
||||||
|
linear-gradient(180deg, #07110f 0%, #070d11 60%, #06090d 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
@@ -827,141 +840,761 @@ code, .mono {
|
|||||||
|
|
||||||
/* ============ Client overview ============ */
|
/* ============ Client overview ============ */
|
||||||
|
|
||||||
.client-hero {
|
.app.client-app {
|
||||||
display: grid;
|
--client-bg: oklch(0.965 0.006 145);
|
||||||
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
|
--client-panel: oklch(0.995 0.003 145);
|
||||||
gap: var(--space-4);
|
--client-control: oklch(0.955 0.006 145);
|
||||||
align-items: stretch;
|
--client-border: oklch(0.86 0.012 145);
|
||||||
background: var(--surface);
|
--client-text: oklch(0.24 0.014 145);
|
||||||
border: 1px solid var(--border);
|
--client-muted: oklch(0.53 0.014 145);
|
||||||
border-radius: var(--radius-card);
|
--client-accent: oklch(0.62 0.14 151);
|
||||||
padding: var(--space-6);
|
--client-accent-soft: oklch(0.92 0.045 151);
|
||||||
box-shadow: var(--shadow-card);
|
grid-template-rows: 1fr;
|
||||||
}
|
color-scheme: light;
|
||||||
.client-mode .app-main {
|
background: var(--client-bg);
|
||||||
max-width: 1120px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.client-hero-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.client-hero-main h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
.client-hero-main p {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.client-hero-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.client-hero-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-content: center;
|
|
||||||
}
|
|
||||||
.client-hero-meta > div {
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-input);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
.client-hero-meta strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.copy-stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.client-setup {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
.switch-row {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-input);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
.switch-row span {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.switch-row input {
|
|
||||||
width: 44px;
|
|
||||||
height: 24px;
|
|
||||||
flex: 0 0 44px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
.copy-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-input);
|
|
||||||
}
|
|
||||||
.copy-field .text-mono {
|
|
||||||
margin-top: 4px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.client-flow {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.flow-node {
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-input);
|
|
||||||
}
|
|
||||||
.flow-node strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.flow-arrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.app-body.client-mode {
|
||||||
.client-hero {
|
background: var(--client-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-mode .app-main {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 24px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell {
|
||||||
|
width: min(100%, 1100px);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 520px;
|
||||||
|
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-state-copy {
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.55);
|
||||||
|
transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']::before {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: scale(1.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:hover:not(:disabled) {
|
||||||
|
transform: translateY(-3px) scale(1.08);
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true'] {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.92);
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power svg {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: drop-shadow(0 0 0 transparent);
|
||||||
|
transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1), transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:hover:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled) svg {
|
||||||
|
transform: scale(0.88);
|
||||||
|
filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent));
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:active:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true'] svg {
|
||||||
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section h2 {
|
||||||
|
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail {
|
||||||
|
min-height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration {
|
||||||
|
display: block;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section p {
|
||||||
|
min-height: 0;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-state-reveal {
|
||||||
|
0% { opacity: 0; filter: blur(5px); }
|
||||||
|
100% { opacity: 1; filter: blur(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% + 130px);
|
||||||
|
width: min(360px, calc(50% - 154px));
|
||||||
|
display: grid;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription {
|
||||||
|
position: relative;
|
||||||
|
min-height: 88px;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:disabled {
|
||||||
|
color: var(--client-accent);
|
||||||
|
cursor: wait;
|
||||||
|
animation: client-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-editing .client-subscription-refresh {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-timing-out .client-subscription-edit::after {
|
||||||
|
animation: client-subscription-timeout 5s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-subscription-timeout {
|
||||||
|
0% {
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.08;
|
||||||
|
box-shadow: 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input::placeholder {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input.has-value {
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input:-webkit-autofill,
|
||||||
|
.client-subscription-edit input:-webkit-autofill:hover,
|
||||||
|
.client-subscription-edit input:-webkit-autofill:focus {
|
||||||
|
box-shadow: 0 0 0 1000px var(--client-bg) inset;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 42px;
|
||||||
|
right: 42px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
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-heading > span {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button 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), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button:hover strong {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage {
|
||||||
|
width: min(100%, 250px);
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: -10px auto 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > span {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated {
|
||||||
|
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated > strong {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated .client-usage-bar i {
|
||||||
|
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-usage-glow {
|
||||||
|
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-bar-flare {
|
||||||
|
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong small {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar {
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--client-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--client-accent);
|
||||||
|
box-shadow: 0 0 8px var(--client-accent);
|
||||||
|
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-servers {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(100%, 210px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 50px;
|
||||||
|
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;
|
||||||
|
animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving .client-server {
|
||||||
|
animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-leave {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-domain-button:focus-visible,
|
||||||
|
.client-server:focus-visible,
|
||||||
|
.client-power:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 220px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxy-label {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxy-address {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button {
|
||||||
|
position: relative;
|
||||||
|
width: 86px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.76;
|
||||||
|
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-copy-label {
|
||||||
|
transition: opacity 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button.is-copied .client-copy-label {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-copy-feedback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--client-accent);
|
||||||
|
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-copy-fade {
|
||||||
|
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
||||||
|
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:hover {
|
||||||
|
color: var(--client-text);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:active {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button.is-copied {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: 560px) {
|
||||||
|
.client-mode .app-main {
|
||||||
|
padding: 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel {
|
||||||
|
position: static;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 26px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 6px;
|
||||||
}
|
}
|
||||||
.client-flow {
|
|
||||||
grid-template-columns: 1fr;
|
.client-form {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.flow-arrow {
|
|
||||||
justify-content: center;
|
}
|
||||||
transform: rotate(90deg);
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.client-power,
|
||||||
|
.client-power::before,
|
||||||
|
.client-power svg,
|
||||||
|
.client-usage-bar i,
|
||||||
|
.client-subscription-refresh,
|
||||||
|
.client-usage,
|
||||||
|
.client-usage > strong,
|
||||||
|
.client-server,
|
||||||
|
.client-proxies button,
|
||||||
|
.client-copy-feedback,
|
||||||
|
.client-subscription-edit,
|
||||||
|
.client-subscription-edit::after,
|
||||||
|
.client-subscription-summary,
|
||||||
|
.client-subscription-summary strong {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
.copy-field {
|
|
||||||
align-items: flex-start;
|
.client-state-copy h2,
|
||||||
flex-direction: column;
|
.client-state-detail > * {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
src/web/utils/clientControls.js
Normal file
64
src/web/utils/clientControls.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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) {
|
||||||
|
const value = String(subscriptionHost || '');
|
||||||
|
try {
|
||||||
|
return new URL(value).host;
|
||||||
|
} catch {
|
||||||
|
return value.split('/')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localProxyUrls(port = 8082) {
|
||||||
|
return {
|
||||||
|
socks5: `socks5://127.0.0.1:${port}`,
|
||||||
|
http: `http://127.0.0.1:${port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
const total = Math.max(0, Number(userInfo.total) || 0);
|
||||||
|
const used = upload + download;
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent: total ? Math.min(100, (used / total) * 100) : null,
|
||||||
|
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
|
||||||
|
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
|
||||||
|
if (!Number.isFinite(days)) return '';
|
||||||
|
if (days <= 0) return 'срок истёк';
|
||||||
|
const mod10 = days % 10;
|
||||||
|
const mod100 = days % 100;
|
||||||
|
const unit = mod10 === 1 && mod100 !== 11
|
||||||
|
? 'день'
|
||||||
|
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
|
||||||
|
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} = await import("../../src/server/sharedProxy.js");
|
||||||
|
|
||||||
|
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||||
|
const info = buildSharedProxyInfo({
|
||||||
|
appMode: "gateway",
|
||||||
|
proxyPort: 8080,
|
||||||
|
running: true,
|
||||||
|
hostHeader: "192.168.50.111:3456",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(info.available, true);
|
||||||
|
assert.deepEqual(info.proxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: "http://192.168.50.111:8080",
|
||||||
|
socksUrl: "socks5://192.168.50.111:8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||||
|
const patch = await checkSharedProxyGateway(
|
||||||
|
"http://192.168.50.111:3456",
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
proxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(patch.sharedProxyEnabled, true);
|
||||||
|
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||||
|
assert.deepEqual(patch.sharedProxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
|
|||||||
const { buildGatewayConfig } = await import(
|
const { buildGatewayConfig } = await import(
|
||||||
`../../src/server/singbox.js?client-mode=${Date.now()}`
|
`../../src/server/singbox.js?client-mode=${Date.now()}`
|
||||||
);
|
);
|
||||||
|
const clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
|
||||||
|
|
||||||
const subscriptionConfig = {
|
const subscriptionConfig = {
|
||||||
outbounds: [
|
outbounds: [
|
||||||
@@ -27,6 +28,7 @@ const subscriptionConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("client mode exposes only the local mixed proxy inbound", () => {
|
test("client mode exposes only the local mixed proxy inbound", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -34,21 +36,24 @@ test("client mode exposes only the local mixed proxy inbound", () => {
|
|||||||
["mixed-in"],
|
["mixed-in"],
|
||||||
);
|
);
|
||||||
assert.equal(config.inbounds[0].type, "mixed");
|
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", () => {
|
test("client mode routes mixed proxy fallback to the selected VPN", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
assert.deepEqual(config.route.rule_set, []);
|
assert.deepEqual(config.route.rule_set, []);
|
||||||
|
assert.equal(config.route.auto_detect_interface, undefined);
|
||||||
assert.deepEqual(config.route.rules, [
|
assert.deepEqual(config.route.rules, [
|
||||||
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("client home bypass routes the local proxy directly", () => {
|
test("client home bypass routes the local proxy directly", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(process.env.DATA_DIR, "client-settings.json"),
|
clientSettingsPath,
|
||||||
JSON.stringify({ homeBypassEnabled: true }),
|
JSON.stringify({ homeBypassEnabled: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -59,3 +64,68 @@ test("client home bypass routes the local proxy directly", () => {
|
|||||||
{ inbound: ["mixed-in"], outbound: "direct" },
|
{ inbound: ["mixed-in"], outbound: "direct" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("client home bypass can build direct proxy without local VPN", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
clientSettingsPath,
|
||||||
|
JSON.stringify({ homeBypassEnabled: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||||
|
|
||||||
|
assert.deepEqual(config.outbounds, [
|
||||||
|
{ type: "direct", tag: "direct" },
|
||||||
|
{ type: "block", tag: "block" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "direct" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client mode ignores saved proxy port outside the published single port", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
clientSettingsPath,
|
||||||
|
JSON.stringify({ proxyPort: 8085 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||||
|
|
||||||
|
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "test-vpn" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client shared proxy mode routes local proxy to gateway socks outbound", () => {
|
||||||
|
fs.rmSync(clientSettingsPath, { force: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
clientSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
|
||||||
|
|
||||||
|
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
|
||||||
|
assert.deepEqual(
|
||||||
|
config.outbounds.find((outbound) => outbound.tag === "shared-proxy"),
|
||||||
|
{
|
||||||
|
type: "socks",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: "192.168.50.111",
|
||||||
|
server_port: 8080,
|
||||||
|
version: "5",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ["mixed-in"], outbound: "shared-proxy" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
66
test/web/client-controls.test.js
Normal file
66
test/web/client-controls.test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
formatConnectionDuration,
|
||||||
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
|
} 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
|
upload: 30,
|
||||||
|
download: 80,
|
||||||
|
total: 100,
|
||||||
|
used: 110,
|
||||||
|
percent: 100,
|
||||||
|
expiresAt: new Date(2000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription expiry uses Russian day forms', () => {
|
||||||
|
const now = Date.parse('2026-07-11T00:00:00Z');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-12T00:00:00Z'), now), 'остался 1 день');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user