Remove legacy vpn proxy code and simplify the client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 11:18:03 +03:00
parent e19d33adb9
commit 9d4f312595
53 changed files with 471 additions and 11511 deletions

View File

@@ -1,6 +1,5 @@
node_modules
.vpn-proxy
_archive
.git
.gitea
.github

View File

@@ -1,8 +1,7 @@
PORT=3456
APP_MODE=gateway
CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT_START=8082
CLIENT_PROXY_PORT_END=8082
CLIENT_PROXY_PORT=8082
BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.12.13
INSTALL_RUNTIME_DEPS=true
@@ -13,5 +12,4 @@ TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
ROUTING_RU_DIRECT=true
LOG_LEVEL=info

4
.gitignore vendored
View File

@@ -1,12 +1,8 @@
# Local archive with the previous implementation and runtime secrets
_archive/
# Runtime state
.env
*.env.local
data/
.vpn-proxy/
.superpowers/
.worktrees/
# Node/Vite

View File

@@ -1,142 +0,0 @@
# 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 изменения.

View File

@@ -6,11 +6,11 @@ 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.
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
## 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.
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
## Brand Personality
@@ -18,7 +18,7 @@ Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet
## 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.
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
## Design Principles

490
README.md
View File

@@ -1,481 +1,43 @@
# VPN Proxy
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
Один компактный VPN-клиент в двух режимах:
## macOS: локальный Docker-клиент
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy;
- `client` — локальный proxy-клиент для macOS.
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
## Gateway
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
cp .env.example .env
docker compose -f docker-compose.gateway.yml up -d --build
```
После запуска по умолчанию:
Интерфейс: `http://<gateway-ip>:3456`.
- UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8082` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
После подключения экран показывает:
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
- `Gateway` — адрес, который можно назначить устройству как основной шлюз;
- `Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`.
Весь перехваченный публичный TCP/UDP и весь proxy-трафик идут через выбранный VPN. Приватные и локальные сети не перехватываются, чтобы сохранить доступ к Gateway и LAN.
Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`.
## macOS client
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
./scripts/install-macos-client.sh
```
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
По умолчанию интерфейс доступен на `http://127.0.0.1:3456`, локальный HTTP/SOCKS5 proxy — на `127.0.0.1:8082`.
## Проверка
```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
npm test
npm run build
docker compose -f docker-compose.gateway.yml config
docker compose -f docker-compose.client.yml config
```
После запуска скрипт проверяет, что 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:
```bash
docker compose -f docker-compose.client.yml up -d --build
```
Перезапуск и логи:
```bash
cd ~/.vpn-proxy-client
docker compose -f docker-compose.client.yml logs -f
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-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
---
## Архитектура
```
Клиент (ПК/телефон)
│ TCP/UDP трафик
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
│ └─ FORWARD + MASQUERADE → обычный internet path
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
├─ приватные CIDR (RFC1918, ...) → RETURN
└─ TCP/UDP → TPROXY :7895
sing-box (tproxy inbound :7895)
роутинг по правилам
┌──────────┼──────────┐
▼ ▼ ▼
direct VPN out block
```
ПК-приложения, которым нужен VPN явно:
```
Windows app → ProxiFyre/Proxifier → gateway:8080 → sing-box mixed-in → global rules → default VPN
```
**Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса.
---
## Стек
| Слой | Технология |
| ---------------- | ------------------------------------------------------------- |
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
| Перехват трафика | iptables TProxy + iproute2 policy routing |
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
| Веб-интерфейс | React 18 + Vite 7, SPA |
---
## Как работает прозрачное проксирование
### 1. TProxy и policy routing
При старте контейнера `entrypoint.sh` настраивает ядро:
```bash
# Policy routing: пакеты с меткой TPROXY_MARK уходят через loopback
ip rule add fwmark 1 table 100
ip route replace local 0.0.0.0/0 dev lo table 100
# Цепочка iptables (порядок правил — критичен)
iptables -t mangle -N VPN_PROXY_TPROXY
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
iptables -N VPN_PROXY_FWD_BYPASS
iptables -t nat -N VPN_PROXY_NAT_BYPASS
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1
-p udp → TPROXY :7895 mark 1
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
```
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
### 2. Маршрутизация внутри sing-box
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
| Приоритет | Условие | Действие |
| --------- | ------------------------------------------- | ---------------------------------------- |
| 1 | `ip_is_private: true` | `direct` (защита LAN) |
| 2 | Global custom rules | `direct` / VPN / `block` для всех inbound |
| 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` |
| 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` |
| 5 | Proxy default для `mixed-in` | по умолчанию VPN |
| 6 | Transparent default для unknown devices | по умолчанию VPN |
| 7 | Всё остальное (`final`) | `direct` |
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
### 3. Bypass Mode (весь трафик напрямую)
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
---
## Direct Bypass Cache (ipset)
Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.
Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.
**Цепочка событий:**
1. sing-box маршрутизирует соединение как `direct`, пишет в лог:
`[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`
2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:
```bash
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
```
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
```
-m set --match-set vpn_direct_bypass dst → RETURN
```
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
4. Запись истекает через TTL (по умолчанию 1 час).
```
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
DIRECT_BYPASS_TTL=3600 # TTL в секундах
```
## Профили устройств
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
```json
{
"defaultTransparentMode": "vpn",
"proxyDefaultMode": "vpn",
"devices": [
{
"id": "gaming-pc",
"name": "Gaming PC",
"ip": "192.168.1.50",
"mac": "",
"mode": "direct",
"enabled": true
},
{
"id": "phone",
"name": "Phone",
"ip": "192.168.1.60",
"mode": "vpn",
"enabled": true
}
]
}
```
| Mode | Что делает |
| -------- | ----------------------------------------------------------------- |
| `direct` | fallback устройства после global rules → `direct` |
| `vpn` | fallback устройства после global rules → выбранный VPN |
| `block` | fallback устройства после global rules → `block` |
| `rules` | не задаёт fallback устройства; используется transparent default |
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
---
## Кастомные правила маршрутизации
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
| Поле | Тип | Описание |
| ---------------- | ---------------------------- | ------------------------------------------- |
| `name` | string | Название правила |
| `enabled` | bool | Вкл/выкл |
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
| `domains` | string[] | Точные домены (`example.com`) |
| `domainSuffixes` | string[] | Суффикс домена (`.example.com` + поддомены) |
| `domainKeywords` | string[] | Keyword в имени хоста |
| `ipCidrs` | string[] | IP-диапазоны CIDR |
| `ports` | string[] | Порты или диапазоны (`443`, `8000-9000`) |
| `networks` | `tcp` \| `udp` | Протокол |
| `ruleSets` | string[] | Ссылки на remote rule-set |
UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.
### Remote Rule Sets
В **Настройках** можно добавить произвольные rule-set:
```json
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
```
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
---
## Подписки
Поддерживаемые форматы:
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
---
## Просмотр трафика
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
### Парсинг логов sing-box
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
```
[router] match[2][my-rule] => outbound/direct[direct] ← имя правила
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag] ← соединение
```
1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
3. Тип трафика: `direct` / `vpn` / `block` по outbound
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`
### Группировка и сортировка
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
- **По частоте** — самые частые наверху (по умолчанию)
- **По времени** — последние наверху
---
## Проверка маршрута
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
1. private IP → direct
2. global custom rules
3. geoip-ru / geosite-category-ru → direct
4. `tproxy-in` + device default
5. `mixed-in` + proxy default
6. final → direct
---
## Быстрый старт
```bash
# Сборка фронтенда
npm install && npm run build
# Запуск контейнера
docker compose -f docker-compose.gateway.yml up -d
```
Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
```bash
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
docker compose -f docker-compose.gateway.yml build
docker compose -f docker-compose.gateway.yml up -d
```
Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
```bash
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
```
Скрипт собирает image на `BUILD_HOST`, переносит его на `DEPLOY_HOST` через `docker save | docker load` и запускает без `docker pull`. Если `107`/`111` не являются SSH-алиасами, укажите реальные адреса, например `BUILD_HOST=root@192.168.1.107 DEPLOY_HOST=root@192.168.1.111`.
Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:
```bash
./scripts/build-runtime-base.sh
```
После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.
UI доступен на `http://<gateway-ip>:3456`.
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
---
## Переменные окружения
| Переменная | По умолчанию | Описание |
| ------------------- | -------------------- | -------------------------------------- |
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` |
| `CLIENT_PROXY_PORT` | `8082` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8082` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
| `PORT` | `3456` | Порт веб-интерфейса |
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
| `INSTALL_RUNTIME_DEPS` | `true` | Устанавливать runtime-пакеты в Docker build; `false` для подготовленного runtime base |
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
| `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен |
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
| `RULE_SET_DOWNLOAD_DETOUR` | `vpn` | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер |
| `PROXY_BIND_IP` | `0.0.0.0` | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway |
| `PROXY_FIREWALL` | `true` | Закрыть `PROXY_PORT` не из allowed CIDR |
| `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy |
---
## REST API
| Метод | Путь | Описание |
| --------- | ---------------------- | ------------------------------------ |
| `GET` | `/api/state` | Полное состояние системы |
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
| `POST` | `/api/subscription` | Загрузить подписку по URL |
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
| `GET` | `/api/servers` | Список серверов из кэша |
| `GET/PUT` | `/api/rules` | Кастомные правила |
| `GET/PUT` | `/api/devices` | Профили устройств и default fallback |
| `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set |
| `POST` | `/api/singbox/start` | Запустить sing-box |
| `POST` | `/api/singbox/stop` | Остановить sing-box |
| `POST` | `/api/singbox/restart` | Перезапустить sing-box |
| `POST` | `/api/bypass` | `{ enabled }` — bypass mode |
| `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша |
| `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш |
| `POST` | `/api/route/check` | Симулировать маршрут |
| `POST` | `/api/servers/ping` | TCP-пинг до хоста |
| `GET` | `/api/logs/stream` | SSE системных логов |
| `GET` | `/api/traffic/stream` | SSE трафика |
---
## Структура проекта
```
├── Dockerfile # debian + sing-box + ipset + node
├── entrypoint.sh # iptables/ipset setup → запуск node
├── docker-compose.gateway.yml
├── src/
│ ├── server/
│ │ ├── index.js # HTTP-сервер, управление sing-box, SSE
│ │ ├── singbox.js # генерация конфига sing-box
│ │ ├── subscription.js # парсинг подписок (JSON/VLESS/base64)
│ │ ├── routeMatcher.js # симулятор маршрутизации
│ │ ├── ping.js # TCP-пинг и DNS-resolve
│ │ └── config.js # настройки из env
│ └── web/
│ ├── App.jsx # корневой компонент, глобальный state
│ ├── api.js # обёртка fetch для API
│ └── components/
│ ├── OverviewPage.jsx # дашборд, bypass-toggle
│ ├── LogsPage.jsx # трафик + системные логи
│ ├── RoutingPage.jsx # кастомные правила
│ ├── ServersPage.jsx # подписка и выбор сервера
│ ├── SettingsPage.jsx # rule-sets и настройки
│ └── RouteChecker.jsx # проверка маршрута
└── docs/
└── roadmap.md
```
## Ограничения
- TProxy только IPv4. IPv6 — в roadmap.
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.

View File

@@ -9,15 +9,11 @@ services:
environment:
APP_MODE: client
PORT: ${PORT:-3456}
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_PORT: ${CLIENT_PROXY_PORT:-8082}
PROXY_BIND_IP: 0.0.0.0
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_PROXY: ""
HTTPS_PROXY: ""
@@ -29,7 +25,7 @@ services:
no_proxy: "localhost,127.0.0.1,host.docker.internal"
ports:
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-8082}:${CLIENT_PROXY_PORT:-8082}"
volumes:
- vpn-proxy-client-data:/var/lib/vpn-proxy
- sing-box-client-cache:/var/lib/sing-box

View File

@@ -14,7 +14,8 @@ services:
- NET_ADMIN
- NET_RAW
env_file:
- .env
- path: .env
required: false
environment:
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CACHE: /var/lib/sing-box/cache.db

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,99 +0,0 @@
# Roadmap: VPN Proxy rebuild
## Целевая модель
Проект должен стать multi-mode системой вокруг `sing-box`:
| Режим | Назначение | Runtime | Статус |
| --- | --- | --- | --- |
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | вынесено в `D:\repos\ProxyWarden` |
## Gateway mode
Цель: контейнер, который становится прозрачным gateway для сети.
Требования:
- `sing-box` внутри контейнера.
- `network_mode: host`.
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
- TProxy inbound на `7895`.
- Mixed HTTP/SOCKS inbound на `8080`.
- Web UI на `3456`.
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
- Пользовательские routing lists управляются из UI.
- Генерируется `/etc/sing-box/config.json`.
- `sing-box check` перед применением.
- Restart `sing-box` после применения.
- Idempotent iptables setup.
- Cleanup iptables/ip rule/ip route при остановке контейнера.
Маршрутизация v1:
- private IP ranges -> `direct`.
- пользовательские списки -> `direct`, `vpn` или `block`.
- `geoip-ru` -> `direct`.
- `geosite-category-ru` -> `direct`.
- все остальное -> выбранный VPN outbound.
Порядок правил:
1. safety private-direct, чтобы не ломать LAN.
2. custom routing lists из UI.
3. RU direct rules.
4. default VPN outbound.
Формат пользовательского списка:
- `name`.
- `enabled`.
- `outbound`: `direct`, `vpn`, `block`.
- `domains`: exact domains.
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
- `domainKeywords`: keyword matching.
- `ipCidrs`: CIDR ranges.
- `ports`: TCP/UDP ports.
- `networks`: `tcp`, `udp`.
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
Отдельно решить позже:
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
- IPv6 TProxy.
- nftables backend.
- health checks и smoke diagnostics.
- secret storage через Infisical/Vault/env.
## Desktop proxy mode
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
Требования:
- UI на `3456`.
- Mixed inbound на `8080`.
- Subscription parser.
- Выбор сервера.
- Fallback proxy через `urltest`.
- Direct mode toggle.
- Не требует `NET_ADMIN`.
## Windows gaming mode
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
Статус: вынесено в соседний репозиторий `D:\repos\ProxyWarden`. Этот репозиторий больше не содержит Tauri Windows app, Windows-specific планы/evidence или installer scripts.
## Рабочий порядок
1. Сделать новый gateway root.
2. Реализовать Docker image + entrypoint TProxy lifecycle.
3. Реализовать маленький control-server.
4. Реализовать Vite + React UI для subscription -> server select -> apply.
5. Добавить gateway docs/install script.
6. Потом переносить desktop-proxy.
7. Windows desktop client развивать в `D:\repos\ProxyWarden`.

View File

@@ -1,74 +0,0 @@
# macOS Docker Client 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:** Build a macOS Docker client mode that exposes a local HTTP/SOCKS proxy on `127.0.0.1:8080` with a friendlier UI and a curl installer.
**Architecture:** Reuse the current Node API, React UI, subscription parser, sing-box process manager, and routing rule generator. Add `APP_MODE=client` so the same backend emits a proxy-only sing-box config without TProxy, and use a dedicated Dockerfile/compose path for Mac installation.
**Tech Stack:** Node.js ESM, React/Vite, sing-box, Docker Compose, POSIX shell, `node:test`.
---
### Task 1: Client Mode Config Contract
**Files:**
- Create: `test/server/singbox-client-mode.test.js`
- Modify: `package.json`
- Modify: `src/server/config.js`
- Modify: `src/server/singbox.js`
- Modify: `src/server/index.js`
- [ ] Add `node:test` coverage that proves `APP_MODE=client` config has `mixed-in`, no `tproxy-in`, no transparent fallback, and a VPN proxy fallback.
- [ ] Add `npm test` script.
- [ ] Add `settings.appMode`.
- [ ] Make `buildGatewayConfig()` conditionally emit client-only inbounds and route rules.
- [ ] Expose `mode` and hide irrelevant tproxy fields in public state.
### Task 2: macOS Client Docker Runtime
**Files:**
- Create: `entrypoint.client.sh`
- Create: `Dockerfile.client`
- Create: `docker-compose.client.yml`
- [ ] Add an entrypoint that starts only the Node control server.
- [ ] Add a Dockerfile that builds the Vite frontend inside Docker and installs only client runtime dependencies plus sing-box.
- [ ] Add compose with loopback-only port publishing for UI and proxy.
### Task 3: User-Friendly Client UI
**Files:**
- Create: `src/web/components/ClientOverviewPage.jsx`
- Modify: `src/web/App.jsx`
- Modify: `src/web/components/Sidebar.jsx`
- Modify: `src/web/components/Topbar.jsx`
- Modify: `src/web/components/StatusPane.jsx`
- Modify: `src/web/components/RouteChecker.jsx`
- Modify: `src/web/styles.css`
- [ ] Add a client overview with status, active server, copyable proxy URLs, and macOS setup commands.
- [ ] Hide gateway-only navigation and side status pane in client mode.
- [ ] Rename topbar brand to match current mode.
- [ ] Keep servers, logs, and settings reachable in client mode.
### Task 4: curl Installer and Docs
**Files:**
- Create: `scripts/install-macos-client.sh`
- Modify: `README.md`
- Modify: `.env.example`
- [ ] Add curl-friendly installer with Docker/Git checks and update-or-clone behavior.
- [ ] Document one-line install command and manual compose command.
- [ ] Add client mode environment examples.
### Task 5: Verification
**Commands:**
- `npm test`
- `npm run build`
- `docker compose -f docker-compose.client.yml config`
- [ ] Run all commands and fix any failures.
- [ ] Inspect the diff to confirm existing CI/runtime-base edits remain untouched.

View File

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

View File

@@ -1,48 +0,0 @@
# macOS Docker Client Design
## Goal
Add a simple macOS-friendly Docker client that behaves like the previous local proxy product: the user runs one container, opens a web UI, loads a subscription, chooses a server, and points macOS apps at `127.0.0.1:8080`.
## Product Shape
The client is not a transparent gateway. It must not require router changes, host networking, `NET_ADMIN`, iptables, ipset, or TProxy. The first-screen UI should explain the current proxy state, active server, and exact local proxy addresses. Gateway-only controls remain available only when the app runs in gateway mode.
## Runtime Architecture
`APP_MODE=client` switches the config generator to proxy-only sing-box config:
- one `mixed` inbound on `PROXY_PORT`;
- no `tproxy` inbound;
- custom routing rules still apply before fallback;
- `proxyDefaultMode` controls the mixed proxy fallback and defaults to VPN;
- generated configs still pass `sing-box check` before restart.
The client Docker image builds the React frontend inside Docker so macOS installation does not require local Node.js. Docker publishes only loopback ports:
- `127.0.0.1:3456` for the UI;
- `127.0.0.1:8080` for HTTP/SOCKS proxy.
## Installer
The macOS installer is a curl-friendly shell script. It checks macOS, Docker, Docker Compose, and Git, clones or updates the repository under `~/.vpn-proxy-client`, then runs the client compose file with `--build`. It prints the UI URL, proxy URLs, and optional `networksetup` commands, but does not change system proxy settings automatically.
## UI
Client mode gets a user-facing overview based on the old workflow:
- status: ready, stopped, not configured, applying, error;
- active server and traffic quota;
- copyable HTTP and SOCKS5 proxy URLs;
- short macOS setup commands;
- primary actions: load subscription, choose server, restart, stop.
Gateway terminology such as TProxy, devices, router, transparent fallback, and direct bypass cache is hidden in client mode.
## Verification
Use `node:test` for server config behavior, then run:
- `npm test`;
- `npm run build`;
- `docker compose -f docker-compose.client.yml config`.

71
entrypoint.sh Normal file → Executable file
View File

@@ -5,22 +5,12 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
PROXY_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
# TTL записи в ipset (секунды). По умолчанию 1 час.
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
# Direct bypass cache выключен по умолчанию, потому что он обходит global rules.
DIRECT_BYPASS_CACHE="${DIRECT_BYPASS_CACHE:-false}"
log() {
printf '[gateway-entrypoint] %s\n' "$*"
@@ -38,55 +28,27 @@ cleanup_proxy_firewall() {
}
cleanup_tproxy() {
log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t nat -X "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
# ipset не чистим при завершении — TTL сам истечёт
}
enable_ip_forwarding() {
log "enable IPv4 forwarding for source bypass"
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
return
fi
if command -v sysctl >/dev/null 2>&1; then
elif command -v sysctl >/dev/null 2>&1; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
fi
}
setup_direct_bypass_set() {
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
export DIRECT_BYPASS_CACHE
return
fi
log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
# Экспортируем имя для использования в Node.js через env
export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL DIRECT_BYPASS_CACHE
}
setup_proxy_firewall() {
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
return
fi
log "setup proxy firewall for :${PROXY_PORT} (${PROXY_ALLOWED_CIDRS})"
cleanup_proxy_firewall
ipt -N "$PROXY_INPUT_CHAIN"
for cidr in $PROXY_ALLOWED_CIDRS; do
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
@@ -97,41 +59,17 @@ setup_proxy_firewall() {
}
setup_tproxy() {
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
log "setup tproxy on port ${TPROXY_PORT}"
cleanup_tproxy
enable_ip_forwarding
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
ipt -t mangle -N "$TPROXY_CHAIN"
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
for cidr in $BYPASS_CIDRS; do
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
done
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
done
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
# Включайте только если готовы к тому, что global rules для этих dst IP не будут проверяться.
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN
fi
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
done
@@ -141,7 +79,6 @@ setup_tproxy() {
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
}
setup_direct_bypass_set
setup_tproxy
setup_proxy_firewall
@@ -149,7 +86,6 @@ node /app/src/server/index.js &
APP_PID=$!
shutdown() {
log "shutdown requested"
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
cleanup_proxy_firewall
@@ -157,7 +93,6 @@ shutdown() {
}
trap 'shutdown; exit 0' SIGTERM SIGINT
wait "$APP_PID"
STATUS=$?
cleanup_proxy_firewall

65
package-lock.json generated
View File

@@ -8,15 +8,11 @@
"name": "vpn-proxy-gateway",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^7.0.0"
},
"devDependencies": {}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
@@ -281,59 +277,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -1606,12 +1549,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -11,9 +11,6 @@
"start": "node src/server/index.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -234,7 +234,6 @@ 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")"
@@ -243,8 +242,6 @@ 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}"

View File

@@ -1,101 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
const DEFAULT_CLIENT_SETTINGS = {
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) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
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 = {}) {
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
return {
homeBypassEnabled: Boolean(input.homeBypassEnabled),
proxyPort: normalizeProxyPort(input.proxyPort),
sharedProxyEnabled,
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
sharedProxy,
};
}
export function readClientSettings() {
return normalizeClientSettings({
...DEFAULT_CLIENT_SETTINGS,
proxyPort: settings.proxyPort,
...readJson(settings.clientSettingsPath, {}),
});
}
export function writeClientSettings(input) {
const normalized = normalizeClientSettings({
...readClientSettings(),
...(input && typeof input === "object" ? input : {}),
});
writeJson(settings.clientSettingsPath, normalized);
return normalized;
}

View File

@@ -9,29 +9,12 @@ const proxyPort = parsePort(
process.env.PROXY_PORT,
process.env.APP_MODE === "client" ? 8082 : 8080,
);
const clientProxyPortStart = parsePort(
process.env.CLIENT_PROXY_PORT_START,
proxyPort,
);
const clientProxyPortEnd = parsePort(
process.env.CLIENT_PROXY_PORT_END,
clientProxyPortStart,
);
export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: parsePort(process.env.PORT, 3456),
proxyPort,
clientProxyPortStart,
clientProxyPortEnd,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
tproxySourceBypassChain:
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
tproxySourceForwardChain:
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
tproxySourceNatChain:
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,
distDir: process.env.DIST_DIR || "/app/dist",
@@ -39,16 +22,9 @@ export const settings = {
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
statePath: path.join(dataDir, "state.json"),
customRulesPath: path.join(dataDir, "custom-rules.json"),
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
clientSettingsPath: path.join(dataDir, "client-settings.json"),
devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
logLevel: process.env.LOG_LEVEL || "info",
appName: "VPN Proxy Gateway",
};

View File

@@ -1,152 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
export const DEFAULT_DEVICE_MODE = "vpn";
export const DEFAULT_PROXY_MODE = "vpn";
export const TPROXY_INBOUND = "tproxy-in";
export const MIXED_INBOUND = "mixed-in";
const IPISH_RE = /^[\.\d:/]+$/;
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function normalizeDeviceMode(mode, fallback = "rules") {
const value = String(mode || "").trim().toLowerCase();
return DEVICE_MODES.has(value) ? value : fallback;
}
function normalizeDefaultMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
}
function normalizeProxyMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
}
function normalizeIp(ip) {
const value = String(ip || "").trim();
return value && IPISH_RE.test(value) ? value : "";
}
function normalizeMac(mac) {
return String(mac || "").trim();
}
function fromLegacyDeviceRules(input) {
const rules = Array.isArray(input) ? input : [];
const devices = [];
for (const rule of rules) {
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
const mode = normalizeDeviceMode(rule?.outbound, "direct");
sourceIps.forEach((sourceIp, ipIndex) => {
const ip = normalizeIp(sourceIp);
if (!ip) return;
devices.push({
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
enabled: rule.enabled !== false,
ip,
mac: "",
mode,
lastSeen: null,
});
});
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices,
};
}
export function normalizeDeviceProfiles(input) {
const raw =
input && typeof input === "object" && !Array.isArray(input)
? input
: { devices: input };
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
return {
defaultTransparentMode: normalizeDefaultMode(
raw.defaultTransparentMode || raw.defaultMode,
),
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
devices: rawDevices.map((device, index) => ({
id: String(device.id || `dev-${Date.now()}-${index}`),
name: String(device.name || `Устройство ${index + 1}`).trim(),
enabled: device.enabled !== false,
ip: normalizeIp(device.ip || device.sourceIp),
mac: normalizeMac(device.mac),
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
lastSeen: device.lastSeen || null,
})),
};
}
export function readDeviceProfiles() {
if (fs.existsSync(settings.devicesPath)) {
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
}
if (fs.existsSync(settings.deviceRulesPath)) {
return normalizeDeviceProfiles(
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
);
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices: [],
};
}
export function writeDeviceProfiles(value) {
const normalized = normalizeDeviceProfiles(value);
writeJson(settings.devicesPath, normalized);
return normalized;
}
export function normalizeCidr(ip) {
const value = normalizeIp(ip);
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
export function deviceCidrs(devices, modes) {
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
return (Array.isArray(devices) ? devices : [])
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
.map((device) => normalizeCidr(device.ip))
.filter(Boolean);
}
export function legacyDeviceRulesFromProfiles(profiles) {
const { devices } = normalizeDeviceProfiles(profiles);
return devices.map((device) => ({
id: device.id,
name: device.name,
enabled: device.enabled,
sourceIps: device.ip ? [device.ip] : [],
outbound: device.mode === "rules" ? "direct" : device.mode,
}));
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,340 +0,0 @@
// Простой симулятор роутинга sing-box.
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
// мы не можем точно сказать, попадает ли IP/домен в RU.
import net from "node:net";
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
function ipv4ToInt(ip) {
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
if (
parts.length !== 4 ||
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)
)
return null;
return (
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
);
}
function ipInCidr(ip, cidr) {
if (!net.isIP(ip)) return false;
const [addr, maskStr] = String(cidr).split("/");
if (!addr) return false;
if (net.isIPv4(ip) && net.isIPv4(addr)) {
const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10);
if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false;
const ipInt = ipv4ToInt(ip);
const cidrInt = ipv4ToInt(addr);
if (ipInt === null || cidrInt === null) return false;
if (mask === 0) return true;
const m = (~0 << (32 - mask)) >>> 0;
return (ipInt & m) === (cidrInt & m);
}
// IPv6 — упрощённо: точное сравнение строк (без полноценной обработки)
return false;
}
const PRIVATE_CIDRS = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.0.0/16",
];
function isPrivateIp(ip) {
if (!ip) return false;
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
}
function normalizeCidr(ip) {
const value = String(ip || "").trim();
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
function deviceMatchesSourceIp(device, sourceIp) {
if (!device?.ip || !sourceIp) return false;
return ipInCidr(sourceIp, normalizeCidr(device.ip));
}
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return `${vpnTag} (VPN)`;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function likelyRuHost(host) {
const value = String(host || "").toLowerCase();
return value === "ru" || value.endsWith(".ru");
}
function hostMatchesDomain(host, domain) {
if (!host || !domain) return false;
return host.toLowerCase() === domain.toLowerCase();
}
function hostMatchesSuffix(host, suffix) {
if (!host || !suffix) return false;
const h = host.toLowerCase();
const s = suffix.toLowerCase();
return h === s || h.endsWith("." + s) || h.endsWith(s);
}
function hostMatchesKeyword(host, keyword) {
if (!host || !keyword) return false;
return host.toLowerCase().includes(keyword.toLowerCase());
}
function ruleMatches(rule, target) {
const { host = "", ip = "", port, network } = target;
if (!rule?.enabled) return false;
const checks = [];
if (rule.domains?.length) {
checks.push(rule.domains.some((d) => hostMatchesDomain(host, d)));
}
if (rule.domainSuffixes?.length) {
checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d)));
}
if (rule.domainKeywords?.length) {
checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d)));
}
if (rule.ipCidrs?.length) {
if (!ip) return false;
checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr)));
}
if (rule.ports?.length) {
if (port === undefined || port === null || port === "") return false;
const p = Number(port);
checks.push(
rule.ports.some((portStr) => {
const s = String(portStr).trim();
if (s.includes("-")) {
const [from, to] = s.split("-").map((x) => Number(x));
return p >= from && p <= to;
}
return p === Number(s);
}),
);
}
if (rule.networks?.length) {
if (!network) return false;
checks.push(rule.networks.includes(network));
}
if (!checks.length) return false;
return checks.every(Boolean);
}
/**
* Симулирует роутинг и возвращает результат.
* @param {object} target { host, ip, port, network }
* @param {Array} customRules
* @param {object} options { routingRuDirect, vpnTag }
*/
export function matchRoute(target, customRules, options = {}) {
const {
routingRuDirect = true,
vpnTag = "vpn-out",
deviceProfiles = {
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices: [],
},
} = options;
const rules = Array.isArray(customRules) ? customRules : [];
const inbound = target.inbound || TPROXY_INBOUND;
const sourceIp = target.sourceIp || "";
const devices = Array.isArray(deviceProfiles.devices)
? deviceProfiles.devices
: [];
const matchedDevice = devices.find(
(device) =>
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
);
if (
inbound === TPROXY_INBOUND &&
matchedDevice &&
matchedDevice.mode === "bypass"
) {
return {
matched: "kernel-bypass",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name} -> bypass TProxy`,
outbound: "direct",
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
};
}
// 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) {
return {
matched: "system",
ruleIndex: -1,
ruleName: "private IP → direct",
outbound: "direct",
reason: `IP ${target.ip} приватный`,
};
}
// 2. global custom rules apply to every inbound before fallbacks.
for (let i = 0; i < rules.length; i += 1) {
const rule = rules[i];
if (ruleMatches(rule, target)) {
const outbound =
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
return {
matched: "custom",
ruleIndex: i,
ruleId: rule.id,
ruleName: rule.name,
outbound,
reason: "Совпадение по global custom rule",
};
}
}
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
if (routingRuDirect && likelyRuHost(target.host)) {
return {
matched: "geo",
ruleIndex: -2,
ruleName: "geosite-category-ru → direct",
outbound: "direct",
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
};
}
// 4. transparent device defaults.
if (inbound === TPROXY_INBOUND && matchedDevice) {
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
if (outbound) {
return {
matched: "device-default",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name}${matchedDevice.mode}`,
outbound,
reason: "Fallback устройства после global rules",
};
}
}
// 5. explicit proxy default.
if (inbound === MIXED_INBOUND) {
const mode = deviceProfiles.proxyDefaultMode || "vpn";
return {
matched: "proxy-default",
ruleIndex: -1,
ruleName: `mixed-in default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
};
}
// 6. unknown transparent device default.
if (inbound === TPROXY_INBOUND) {
const mode = deviceProfiles.defaultTransparentMode || "vpn";
return {
matched: "transparent-default",
ruleIndex: -1,
ruleName: `transparent default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || "direct",
reason: "Fallback unknown transparent device после global rules",
};
}
// 7. final → direct
return {
matched: "final",
ruleIndex: -3,
ruleName: "final",
outbound: "direct",
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
};
}
/**
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
* по доменам/суффиксам/CIDR — отмечаем конфликт.
*/
export function detectRuleConflicts(rules) {
const list = Array.isArray(rules) ? rules : [];
const conflicts = [];
for (let i = 1; i < list.length; i += 1) {
const cur = list[i];
if (!cur?.enabled) continue;
for (let j = 0; j < i; j += 1) {
const prev = list[j];
if (!prev?.enabled) continue;
// Если outbound одинаковый — это не "конфликт", это дубликат
const sameOutbound = prev.outbound === cur.outbound;
// Проверка перекрытия доменов
const overlaps = [];
// Точные домены покрываются prev.suffix
for (const d of cur.domains || []) {
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
overlaps.push({
kind: "domain",
value: d,
by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}`,
});
}
if ((prev.domains || []).includes(d)) {
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
}
}
// Суффиксы покрываются более общим суффиксом prev
for (const s of cur.domainSuffixes || []) {
if (
(prev.domainSuffixes || []).some(
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
)
) {
overlaps.push({
kind: "suffix",
value: s,
by: "более общий суффикс",
});
}
}
// CIDR
for (const c of cur.ipCidrs || []) {
if ((prev.ipCidrs || []).includes(c)) {
overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" });
}
}
if (overlaps.length) {
conflicts.push({
ruleId: cur.id,
ruleIndex: i,
ruleName: cur.name,
conflictWithId: prev.id,
conflictWithIndex: j,
conflictWithName: prev.name,
severity: sameOutbound ? "info" : "warning",
overlaps,
});
}
}
}
return conflicts;
}

View File

@@ -1,352 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
import {
MIXED_INBOUND,
TPROXY_INBOUND,
normalizeCidr,
readDeviceProfiles,
} from "./devices.js";
import { readClientSettings } from "./clientSettings.js";
import fs from 'node:fs';
import path from 'node:path';
import { settings } from './config.js';
const PROXY_TYPES = new Set([
"vless",
"vmess",
"trojan",
"shadowsocks",
"hysteria2",
]);
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
? subscriptionConfig.outbounds
: [];
const exact = outbounds.find(
(outbound) =>
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
);
if (exact) return exact;
const trimmedTag = String(selectedTag || "").trim();
return outbounds.find(
(outbound) =>
String(outbound.tag || "").trim() === trimmedTag &&
PROXY_TYPES.has(outbound.type),
);
const tag = String(selectedTag || '').trim();
return outbounds.find((outbound) => (
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
));
}
function readCustomRuleSets() {
try {
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
const data = JSON.parse(
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
);
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function ruleSetDownloadDetour(vpnTag) {
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
if (!detour || detour === "vpn") return vpnTag;
return detour;
}
function ruleSets(customRuleSets = [], vpnTag = "direct") {
const downloadDetour = ruleSetDownloadDetour(vpnTag);
const builtIn = settings.routingRuDirect
? [
{
type: "remote",
tag: "geoip-ru",
format: "binary",
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
download_detour: downloadDetour,
},
{
type: "remote",
tag: "geosite-category-ru",
format: "binary",
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
download_detour: downloadDetour,
},
]
: [];
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
.filter((rs) => rs.tag && rs.url)
.map((rs) => ({
type: "remote",
tag: String(rs.tag).trim(),
format: rs.format || "binary",
url: String(rs.url).trim(),
download_detour: downloadDetour,
}));
// Пользовательские rule-sets не должны дублировать встроенные
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
const merged = [
...builtIn,
...custom.filter((rs) => !builtInTags.has(rs.tag)),
];
return merged;
}
function uniqueClean(values) {
return Array.from(
new Set(
(Array.isArray(values) ? values : [])
.map((value) => String(value || "").trim())
.filter(Boolean),
),
);
}
function parsePorts(values) {
return uniqueClean(values)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
}
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = { ...baseRule };
const domains = uniqueClean(customRule.domains);
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
const domainKeywords = uniqueClean(customRule.domainKeywords);
const ipCidrs = uniqueClean(customRule.ipCidrs);
const ports = parsePorts(customRule.ports);
const networks = uniqueClean(customRule.networks).filter((network) =>
["tcp", "udp"].includes(network),
);
if (domains.length) rule.domain = domains;
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
if (ports.length) rule.port = ports;
if (networks.length) rule.network = networks;
const ruleSetsRef = uniqueClean(customRule.ruleSets);
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
if (
!rule.domain &&
!rule.domain_suffix &&
!rule.domain_keyword &&
!rule.ip_cidr &&
!rule.port &&
!rule.network &&
!rule.rule_set
) {
return null;
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
const clientMode = settings.appMode === 'client';
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp';
}
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
return rule;
}
function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean);
}
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return vpnTag;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function deviceDefaultRouteRule(device, vpnTag) {
if (!device?.enabled) return null;
const outbound = modeOutbound(device.mode, vpnTag);
if (!outbound) return null;
const cidr = normalizeCidr(device.ip);
if (!cidr) return null;
return {
inbound: [TPROXY_INBOUND],
source_ip_cidr: [cidr],
outbound,
};
}
function deviceDefaultRouteRules(devices, vpnTag) {
return (Array.isArray(devices) ? devices : [])
.map((device) => deviceDefaultRouteRule(device, vpnTag))
.filter(Boolean);
}
function inboundDefaultRule(inbound, mode, vpnTag) {
const outbound = modeOutbound(mode, vpnTag);
if (!outbound) return null;
return { inbound: [inbound], outbound };
}
function ruDirectRule() {
if (!settings.routingRuDirect) return null;
return {
rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: "direct",
};
}
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
const deviceProfiles = readDeviceProfiles();
const rules = [
{
ip_is_private: true,
outbound: "direct",
},
];
// Global rules apply to every inbound before contextual fallbacks.
rules.push(...customRouteRules(customRules, vpnTag));
const ruRule = ruDirectRule();
if (ruRule) rules.push(ruRule);
if (includeTransparent) {
// Device defaults are only transparent-gateway fallbacks after global rules.
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
}
const proxyFallback = inboundDefaultRule(
MIXED_INBOUND,
deviceProfiles.proxyDefaultMode,
vpnTag,
);
if (proxyFallback) rules.push(proxyFallback);
if (includeTransparent) {
const transparentFallback = inboundDefaultRule(
TPROXY_INBOUND,
deviceProfiles.defaultTransparentMode,
vpnTag,
);
if (transparentFallback) rules.push(transparentFallback);
}
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(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const customRuleSets = readCustomRuleSets();
const clientMode = settings.appMode === "client";
const clientSettings = clientMode ? readClientSettings() : null;
const sharedOutbound =
clientMode && clientSettings?.sharedProxyEnabled
? 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 inbounds = [
...(clientMode
? []
: [
{
type: "tproxy",
tag: "tproxy-in",
listen: "::",
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
]),
...(!clientMode ? [{
type: 'tproxy',
tag: TPROXY_INBOUND,
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
}] : []),
{
type: "mixed",
tag: "mixed-in",
type: 'mixed',
tag: MIXED_INBOUND,
listen: settings.bindIp,
listen_port: mixedProxyPort,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
];
const rules = clientMode
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
: [
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
];
return {
log: {
level: settings.logLevel,
timestamp: true,
},
log: { level: settings.logLevel, timestamp: true },
experimental: {
cache_file: {
enabled: true,
path: settings.cachePath,
},
},
dns: {
independent_cache: true,
cache_file: { enabled: true, path: settings.cachePath },
},
dns: { independent_cache: true },
inbounds,
outbounds: [
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
vpnOutbound,
{ type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' },
],
route: {
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }]
: clientMode
? proxyOnlyRules
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
includeTransparent: !clientMode,
}),
final: "direct",
rule_set: [],
rules,
final: vpnOutbound.tag,
...(clientMode ? {} : { auto_detect_interface: true }),
},
};
@@ -354,24 +73,9 @@ export function buildGatewayConfig(
export function writeSingboxConfig(config) {
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
fs.writeFileSync(
settings.configPath,
JSON.stringify(config, null, 2),
"utf8",
);
}
export function readSingboxConfig() {
if (!fs.existsSync(settings.configPath)) return null;
try {
return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
} catch {
return null;
}
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
}
export function removeSingboxConfig() {
if (fs.existsSync(settings.configPath)) {
fs.rmSync(settings.configPath);
}
fs.rmSync(settings.configPath, { force: true });
}

View File

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

View File

@@ -1,85 +1,22 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api } from './api.js';
import { Topbar } from './components/Topbar.jsx';
import { Sidebar } from './components/Sidebar.jsx';
import { StatusPane } from './components/StatusPane.jsx';
import { OverviewPage } from './components/OverviewPage.jsx';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { ServersPage } from './components/ServersPage.jsx';
import { RoutingPage } from './components/RoutingPage.jsx';
import { LogsPage } from './components/LogsPage.jsx';
import { SettingsPage } from './components/SettingsPage.jsx';
import { ConfigViewer } from './components/ConfigViewer.jsx';
import { Toasts } from './components/Toasts.jsx';
const ROLLBACK_WINDOW_MS = 12_000;
function getInitialPage() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
return valid.includes(hash) ? hash : 'overview';
}
function App() {
const [page, setPage] = useState(getInitialPage());
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = useState([]);
const [devicesConfig, setDevicesConfig] = useState({
defaultTransparentMode: 'vpn',
proxyDefaultMode: 'vpn',
devices: [],
});
const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
const [configOpen, setConfigOpen] = useState(false);
const [pings, setPings] = useState({});
const [toasts, setToasts] = useState([]);
const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error
const [rollbackOffer, setRollbackOffer] = useState(null);
const rulesDirtyRef = useRef(false);
const rulesSaveTimerRef = useRef(null);
const rulesRevisionRef = useRef(0);
const rollbackTimerRef = useRef(null);
function pushToast(toast) {
const id = `t-${Date.now()}-${Math.random()}`;
setToasts((prev) => [...prev, { id, ...toast }]);
}
function dismissToast(id) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
function navigate(p) {
setPage(p);
window.location.hash = `#/${p}`;
}
useEffect(() => {
function onHash() { setPage(getInitialPage()); }
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
async function loadState() {
const data = await api.state();
setState(data);
setServers(data.servers || []);
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
setDevicesConfig(data.devicesConfig || {
defaultTransparentMode: 'vpn',
proxyDefaultMode: 'vpn',
devices: data.devices || [],
});
setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || '');
setPendingTag((current) => current || data.selectedTag || '');
}
useEffect(() => {
@@ -88,496 +25,72 @@ function App() {
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (state?.mode === 'client' && page !== 'overview') {
navigate('overview');
}
}, [state?.mode, page]);
useEffect(() => () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
}, []);
async function withBusy(label, fn, { quiet = false } = {}) {
async function run(action) {
setBusy(true);
setError('');
try {
const result = await fn();
if (!quiet && label && state?.mode !== 'client') {
pushToast({ kind: 'success', title: label });
}
return result;
await action();
await loadState();
} catch (err) {
setError(err.message);
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
throw err;
} finally {
setBusy(false);
}
}
// === Subscription ===
async function fetchSubscription() {
return withBusy('Подписка обновлена', async () => {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
return run(async () => {
const data = await api.subscription.fetch(subscriptionUrl);
setServers(data.servers || []);
if (data.servers?.length) {
if (state?.mode === 'client') {
setSelectedTag('');
setPendingTag('');
} else {
const nextTag = data.servers.some((server) => server.tag === selectedTag)
? selectedTag
: data.servers[0].tag;
setSelectedTag(nextTag);
setPendingTag(nextTag);
}
}
await loadState();
setPendingTag('');
});
}
async function refreshSubscriptionInfo() {
const data = await api.subscription.refreshInfo();
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
setState((current) => current ? {
...current,
userInfo: data.userInfo,
fetchedAt: data.fetchedAt,
} : current);
return data;
}
async function forgetSubscription() {
if (!confirm('Удалить подписку и остановить sing-box?')) return;
return withBusy('Подписка удалена', async () => {
if (!confirm('Удалить подписку и остановить VPN?')) return;
return run(async () => {
await api.subscription.forget();
setSubscriptionUrl('');
setServers([]);
setSelectedTag('');
setPendingTag('');
await loadState();
});
}
// === Apply with rollback offer ===
async function applyServer(tag) {
const target = tag || selectedTag;
if (!target) return;
const previous = state?.selectedTag;
setApplyStatus('applying');
try {
await withBusy('Сервер применён', async () => {
await api.apply(target);
await loadState();
});
setApplyStatus('idle');
if (state?.mode !== 'client' && previous && previous !== target) {
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
}
} catch {
setApplyStatus('error');
}
}
async function rollback() {
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
setRollbackOffer(null);
return withBusy('Откат выполнен', async () => {
const data = await api.rollback();
setSelectedTag(data.selectedTag);
setPendingTag(data.selectedTag);
await loadState();
});
}
// === sing-box control ===
async function stopSingbox(confirmFirst = true) {
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
}
async function restartSingbox() {
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
}
async function clearConfig() {
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
return withBusy('Config сброшен', async () => {
await api.singbox.clear();
setSelectedTag('');
setPendingTag('');
await loadState();
});
}
async function toggleBypass() {
const next = !state?.bypassMode;
return withBusy(
next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён',
async () => {
await api.bypass(next);
await loadState();
},
);
}
async function flushDirectCache() {
return withBusy('Bypass-кэш сброшен', async () => {
await api.directCache.flush();
await loadState();
});
}
// === Devices ===
async function saveDevicesConfig(nextConfig) {
try {
const data = await api.devices.save(nextConfig);
setDevicesConfig({
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn',
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
devices: data.devices || [],
});
setState((prev) => prev ? {
...prev,
devicesUpdatedAt: data.devicesUpdatedAt,
sourceBypassCidrs: data.sourceBypassCidrs,
} : prev);
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
pushToast({
kind: 'warning',
title: 'Bypass сохранён, но не применён',
message: data.sourceBypassResult.error,
duration: 7000,
});
}
} catch (err) {
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
}
}
function addDevice() {
const nextConfig = {
...devicesConfig,
devices: [
...devicesConfig.devices,
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
],
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function updateDevice(id, patch) {
const nextConfig = {
...devicesConfig,
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function removeDevice(id) {
const nextConfig = {
...devicesConfig,
devices: devicesConfig.devices.filter((d) => d.id !== id),
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function updateDeviceDefaults(patch) {
const nextConfig = { ...devicesConfig, ...patch };
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
// === Rules CRUD ===
function emptyRule() {
return {
id: `rule-${Date.now()}`,
name: 'Новое правило',
enabled: true,
outbound: 'direct',
domains: [], domainSuffixes: [], domainKeywords: [],
ipCidrs: [], ports: [], networks: [],
};
}
function queueRulesSave(nextRules) {
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
setRulesSaveStatus('pending');
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
}
async function saveRules(nextRules = customRules, options = {}) {
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
setError('');
setRulesSaveStatus('saving');
try {
const data = await api.rules.save(nextRules);
if (rulesRevisionRef.current === revision) {
rulesDirtyRef.current = false;
setCustomRules(data.rules || []);
setRulesSaveStatus('saved');
await loadState();
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
} else {
setRulesSaveStatus('pending');
}
} catch (err) {
setError(err.message);
setRulesSaveStatus('error');
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
}
}
function saveRulesNow() {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
saveRules(customRules, { silent: false, revision });
}
function updateRule(id, patch) {
setCustomRules((rules) => {
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
queueRulesSave(next);
return next;
});
}
function addRule() {
setCustomRules((rules) => {
const next = [emptyRule(), ...rules];
queueRulesSave(next);
return next;
});
}
function addRuleFromTemplate(tpl) {
setCustomRules((rules) => {
const next = [tpl, ...rules];
queueRulesSave(next);
return next;
});
}
function removeRule(id) {
setCustomRules((rules) => {
const next = rules.filter((r) => r.id !== id);
queueRulesSave(next);
return next;
});
}
function reorderRules(next) {
setCustomRules(next);
queueRulesSave(next);
}
// === Computed ===
const status = useMemo(() => {
if (applyStatus === 'applying') return 'applying';
if (applyStatus === 'error') return 'error';
if (state?.singboxRunning) return 'running';
if (state?.configExists) return 'stopped';
return 'no_config';
}, [state, applyStatus]);
const activeServer = useMemo(
() => servers.find((s) => s.tag === state?.selectedTag) || null,
[servers, state?.selectedTag],
);
const isClientMode = state?.mode === 'client';
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyDevices = Boolean(
state?.devicesUpdatedAt &&
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
);
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
const dirtyRouting = dirtyRules || dirtyDevices;
const dirty = dirtyRouting || dirtyServer;
const sidebarBadges = {
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
};
// === Render ===
if (!state) return <div className="app-loading">VPN</div>;
return (
<div className={`app${isClientMode ? ' client-app' : ''}`}>
{!isClientMode && (
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
)}
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
<div className="app client-app">
<div className="app-body client-mode">
<main className="app-main">
{(page === 'overview' || isClientMode) && (
isClientMode ? (
<ClientOverviewPage
state={state}
status={status}
activeServer={activeServer}
busy={busy}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
onForgetSubscription={forgetSubscription}
onApply={applyServer}
onRestart={restartSingbox}
onStop={() => stopSingbox(false)}
/>
) : (
<OverviewPage
state={state}
status={status}
busy={busy}
onRestart={restartSingbox}
onStop={stopSingbox}
onShowConfig={() => setConfigOpen(true)}
onNav={navigate}
onBypassToggle={toggleBypass}
onFlushDirectCache={flushDirectCache}
/>
)
)}
{page === 'servers' && !isClientMode && (
<ServersPage
state={state}
servers={servers}
selectedTag={selectedTag}
setSelectedTag={setSelectedTag}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onApply={applyServer}
onRollback={rollback}
pings={pings}
setPings={setPings}
pushToast={pushToast}
/>
)}
{page === 'routing' && !isClientMode && (
<RoutingPage
rules={customRules}
saveStatus={rulesSaveStatus}
busy={busy}
onAdd={addRule}
onAddTemplate={addRuleFromTemplate}
onUpdate={updateRule}
onRemove={removeRule}
onSaveNow={saveRulesNow}
onReorder={reorderRules}
devicesConfig={devicesConfig}
onUpdateDeviceDefaults={updateDeviceDefaults}
onAddDevice={addDevice}
onUpdateDevice={updateDevice}
onRemoveDevice={removeDevice}
/>
)}
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && !isClientMode && (
<SettingsPage
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetchSubscription={fetchSubscription}
onForgetSubscription={forgetSubscription}
onShowConfig={() => setConfigOpen(true)}
onClearConfig={clearConfig}
pushToast={pushToast}
/>
)}
{/* Sticky bar — для routing/servers */}
{(page === 'routing' && dirtyRouting) && (
<div className="sticky-bar">
<div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
<strong>
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
</strong>
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
</div>
<div className="btn-group">
{rulesSaveStatus !== 'saved' && (
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
)}
{state?.selectedTag && (
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
Применить config
</button>
)}
</div>
</div>
)}
{(page === 'servers' && dirtyServer) && (
<div className="sticky-bar">
<div className="flex">
<span className="dot warning" />
<strong>Сервер не применён</strong>
<small className="muted">Выбран: {pendingTag}</small>
</div>
<div className="btn-group">
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
Применить
</button>
</div>
</div>
)}
</main>
{!isClientMode && (
<StatusPane
<ClientOverviewPage
state={state}
busy={busy}
onStop={stopSingbox}
onRestart={restartSingbox}
onShowConfig={() => setConfigOpen(true)}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
onForgetSubscription={forgetSubscription}
onApply={(tag) => run(() => api.apply(tag))}
onRestart={() => run(api.singbox.restart)}
onStop={() => run(api.singbox.stop)}
/>
)}
</main>
</div>
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
<Toasts items={toasts} onDismiss={dismissToast} />
{rollbackOffer && (
<div className="toasts">
<div className="toast warning">
<span className="dot warning" style={{ marginTop: 4 }} />
<div className="body">
<strong>Сервер применён</strong>
<small>Можно откатиться к «{rollbackOffer.to}»</small>
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
Откатить
</button>
</div>
<button onClick={() => setRollbackOffer(null)}>×</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,113 +2,36 @@ async function request(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
'content-type': 'application/json',
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok || (data && data.success === false)) {
throw new Error(
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
);
if (!response.ok || data?.success === false) {
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
}
return data;
}
export const api = {
state: () => request("/api/state"),
config: () => request("/api/config"),
rules: {
get: () => request("/api/rules"),
save: (rules) =>
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
conflicts: () => request("/api/rules/conflicts"),
},
deviceRules: {
get: () => request("/api/device-rules"),
save: (deviceRules) =>
request("/api/device-rules", {
method: "PUT",
body: JSON.stringify({ deviceRules }),
}),
},
devices: {
get: () => request("/api/devices"),
save: (devicesConfig) =>
request("/api/devices", {
method: "PUT",
body: JSON.stringify(devicesConfig),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>
request("/api/rule-sets", {
method: "PUT",
body: JSON.stringify({ ruleSets }),
}),
lookup: (tag, url) =>
request("/api/rule-sets/lookup", {
method: "POST",
body: JSON.stringify({ tag, url }),
}),
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
},
state: () => request('/api/state'),
subscription: {
fetch: (url) =>
request("/api/subscription/fetch", {
method: "POST",
body: JSON.stringify({ url }),
}),
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
forget: () => request("/api/subscription", { method: "DELETE" }),
},
apply: (selectedTag) =>
request("/api/apply", {
method: "POST",
body: JSON.stringify({ selectedTag }),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
rollback: () => request("/api/apply/rollback", { method: "POST" }),
refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (selectedTag) => request('/api/apply', {
method: 'POST',
body: JSON.stringify({ selectedTag }),
}),
singbox: {
stop: () => request("/api/singbox/stop", { method: "POST" }),
restart: () => request("/api/singbox/restart", { method: "POST" }),
clear: () => request("/api/singbox/clear", { method: "POST" }),
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (host, port) =>
request("/api/servers/ping", {
method: "POST",
body: JSON.stringify({ host, port }),
}),
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
},
bypass: (enabled) =>
request("/api/bypass", {
method: "POST",
body: JSON.stringify({ enabled }),
}),
directCache: {
get: () => request("/api/direct-cache"),
flush: () => request("/api/direct-cache", { method: "DELETE" }),
},
route: {
check: ({ host, ip, port, network, sourceIp, inbound }) =>
request("/api/route/check", {
method: "POST",
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
}),
},
configValidate: () => request("/api/config/validate", { method: "POST" }),
};

View File

@@ -1,61 +0,0 @@
import React, { useState } from 'react';
/**
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
* Невалидные элементы помечаются красным.
*/
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
const [draft, setDraft] = useState('');
function commit(text) {
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
if (!parts.length) return;
const next = Array.from(new Set([...value, ...parts]));
onChange(next);
setDraft('');
}
function remove(item) {
onChange(value.filter((v) => v !== item));
}
function onKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
}
function onPaste(e) {
const text = e.clipboardData.getData('text');
if (text && splitter.test(text)) {
e.preventDefault();
commit(text);
}
}
return (
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
{value.map((item) => {
const invalid = validate ? !validate(item) : false;
return (
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
{item}
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
</span>
);
})}
<input
className="chip-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onBlur={() => draft.trim() && commit(draft)}
placeholder={value.length ? '' : placeholder}
/>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { formatBytes } from '../utils/format.js';
export function ClientOverviewPage({
state,
busy,
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
@@ -25,6 +26,7 @@ export function ClientOverviewPage({
onRestart,
onStop,
}) {
const isGateway = state?.mode === 'gateway';
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || '';
@@ -41,7 +43,8 @@ export function ClientOverviewPage({
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 gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
@@ -152,7 +155,7 @@ export function ClientOverviewPage({
async function copyProxy(kind) {
try {
await navigator.clipboard.writeText(proxyUrls[kind]);
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
setCopiedProxy(kind);
setTimeout(() => setCopiedProxy(''), 800);
} catch {
@@ -191,7 +194,7 @@ export function ClientOverviewPage({
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
disabled={busy || (!connected && !canStart)}
onClick={toggleConnection}
>
@@ -201,7 +204,9 @@ export function ClientOverviewPage({
</button>
<div className="client-state-copy" aria-live="polite">
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
{connected ? 'VPN включён' : 'VPN выключен'}
{isGateway
? connected ? 'Gateway включён' : 'Gateway выключен'
: connected ? 'VPN включён' : 'VPN выключен'}
</h2>
<div className="client-state-detail">
{connected ? (
@@ -216,12 +221,28 @@ export function ClientOverviewPage({
</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>
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-point">
<span className="client-proxy-label">Gateway</span>
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
</button>
</div>
)}
<div className="client-access-point">
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
@@ -237,11 +258,14 @@ export function ClientOverviewPage({
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
</button>
))}
</div>
</div>
</section>
</section>
)}
{error && <p className="client-error" role="alert">{error}</p>}
<div className="client-form">
<div
ref={subscriptionRef}

View File

@@ -1,85 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
export function ConfigViewer({ open, onClose }) {
const [config, setConfig] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
if (!open) return;
let cancelled = false;
setConfig(null);
setError('');
api.config()
.then((data) => { if (!cancelled) setConfig(data.config); })
.catch((err) => { if (!cancelled) setError(err.message); });
return () => { cancelled = true; };
}, [open]);
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
const highlighted = useMemo(() => {
if (!search || !text) return text;
try {
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
return text.split(re);
} catch {
return text;
}
}, [text, search]);
if (!open) return null;
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
function download() {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sing-box-config.json';
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3>sing-box config</h3>
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
</div>
<div className="btn-group">
<input
className="input"
placeholder="Поиск…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 160 }}
/>
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
</div>
</div>
<div className="modal-body">
{error && <div className="conflict-banner danger">{error}</div>}
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
{config && (
<pre className="config-view">
{Array.isArray(highlighted)
? highlighted.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
</React.Fragment>
))
: text}
</pre>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,337 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { formatTime } from '../utils/format.js';
const MAX_ENTRIES = 800;
const MAX_TRAFFIC = 500;
const GROUP_WINDOW_MS = 30_000;
function normalizeLine(line) {
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
}
function groupEntries(entries) {
const out = [];
for (const e of entries) {
const key = `${e.level}|${normalizeLine(e.line)}`;
const last = out[out.length - 1];
const ts = new Date(e.ts).getTime();
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
last.count += 1;
last._lastTs = ts;
last.lastTs = e.ts;
} else {
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
return out;
}
const CATEGORY_BADGE = {
direct: { cls: 'success', label: 'direct' },
vpn: { cls: 'info', label: 'VPN' },
block: { cls: 'danger', label: 'block' },
other: { cls: '', label: 'other' },
};
function getDeviceName(sourceIp, devices) {
if (!sourceIp || !devices?.length) return null;
for (const d of devices) {
if (d.enabled === false) continue;
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name;
}
return null;
}
function groupTraffic(list, sortBy = 'time') {
const map = new Map();
for (const e of list) {
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
const ts = new Date(e.ts).getTime();
if (map.has(key)) {
const g = map.get(key);
g.count++;
g._lastTs = ts;
g.lastTs = e.ts;
} else {
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
const arr = Array.from(map.values());
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
return arr.sort((a, b) => b._lastTs - a._lastTs);
}
function TrafficTab({ devices = [] }) {
const [traffic, setTraffic] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
const [search, setSearch] = useState('');
const [grouped, setGrouped] = useState(true);
const [sortBy, setSortBy] = useState('count'); // time | count
const [autoscroll, setAutoscroll] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/traffic/stream');
source.onmessage = (ev) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(ev.data);
setTraffic((prev) => {
const next = [...prev, entry];
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = traffic;
if (filter !== 'all') list = list.filter((e) => e.category === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) =>
e.host?.toLowerCase().includes(s) ||
String(e.port || '').includes(s) ||
e.outbound?.toLowerCase().includes(s) ||
e.matchedRule?.toLowerCase().includes(s) ||
e.sourceIp?.toLowerCase().includes(s) ||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
);
}
return grouped ? groupTraffic(list, sortBy) : list;
}, [traffic, filter, search, grouped, sortBy, devices]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
const counts = useMemo(() => {
const c = { direct: 0, vpn: 0, block: 0 };
for (const e of traffic) if (e.category in c) c[e.category]++;
return c;
}, [traffic]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<input
className="input"
placeholder="Поиск: host, порт, правило…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 180 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все ({traffic.length})</option>
<option value="direct">direct ({counts.direct})</option>
<option value="vpn">VPN ({counts.vpn})</option>
<option value="block">block ({counts.block})</option>
</select>
<label className="checkbox">
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
Группировать
</label>
{grouped && (
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
<option value="count">По частоте</option>
<option value="time">По времени</option>
</select>
)}
<label className="checkbox">
<input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />
Автоскролл
</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>
{paused ? '▶ Продолжить' : '⏸ Пауза'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
>
Очистить
</button>
</div>
{traffic.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
Ожидаем трафик Убедитесь что sing-box запущен и уровень логов не выше INFO.
</div>
) : (
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th style={{ width: 70 }}>Время</th>
<th style={{ width: 70 }}>Туннель</th>
<th style={{ width: 110 }}>Устройство</th>
<th>Хост / IP</th>
<th style={{ width: 55 }}>Порт</th>
<th>Правило</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{filtered.map((e, i) => {
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
const deviceName = getDeviceName(e.sourceIp, devices);
return (
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
<td>
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
</td>
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
{deviceName
? <span style={{ fontSize: 11 }}>{deviceName}</span>
: e.sourceIp
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
<td className="muted text-mono">{e.port || '—'}</td>
<td>
{e.matchedRule
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
{e.count > 1 && <span className="repeat">×{e.count}</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export function LogsPage({ devices = [] }) {
const [tab, setTab] = useState('traffic'); // traffic | logs
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [autoscroll, setAutoscroll] = useState(true);
const [grouped, setGrouped] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/logs/stream');
source.onmessage = (event) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(event.data);
setEntries((prev) => {
const next = [...prev, entry];
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = entries;
if (filter !== 'all') list = list.filter((e) => e.level === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
}
return grouped ? groupEntries(list) : list;
}, [entries, filter, search, grouped]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
function copy(text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
<div className="card-header">
<h2>Логи sing-box</h2>
<div className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
</div>
</div>
{tab === 'traffic' && <TrafficTab devices={devices} />}
{tab === 'logs' && (
<>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тексту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все уровни</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="debug">debug</option>
</select>
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
</div>
<div ref={containerRef} className="logs-stream">
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
{filtered.map((entry, index) => {
const text = normalizeLine(entry.line);
if (grouped && entry.count > 1) {
return (
<div key={`${entry.ts}-${index}`} className="log-group">
<span className="log-time mono">{formatTime(entry.ts)}</span>
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
{entry.level}
</span>
<span className="log-text">{text}</span>
<span className="repeat">×{entry.count}</span>
</div>
);
}
return (
<div
key={`${entry.ts}-${index}`}
className={`log-line ${entry.level}`}
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
title="Двойной клик — скопировать"
>
<span className="log-time">{formatTime(entry.ts)}</span>
<span className="log-level">{entry.level}</span>
<span className="log-text">{text}</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -1,192 +0,0 @@
import React, { useEffect, useState } from 'react';
import { formatRelative, formatBytes } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
import { api } from '../api.js';
function StatusHero({ state, status }) {
const text = {
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
error: { title: '🔴 Ошибка', kind: 'danger' },
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
}[status];
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
: 'нет данных';
return (
<div className="card">
<div className="flex-between">
<div>
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
<small className="muted">
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
</small>
</div>
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
</div>
<div className="divider" />
<div className="grid-3">
<div>
<small className="muted">Активный сервер</small>
<div style={{ marginTop: 4 }}>
{state?.selectedTag ? (
<>
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
</>
) : <span className="muted">Не выбран</span>}
</div>
</div>
<div>
<small className="muted">Трафик</small>
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
</div>
<div>
<small className="muted">Правил маршрутизации</small>
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
</div>
</div>
</div>
);
}
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
return (
<div className="card">
<div className="card-header">
<h3>Быстрые действия</h3>
</div>
<div className="btn-group">
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
Сменить сервер
</button>
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
Перезапустить
</button>
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
Остановить
</button>
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
Показать config
</button>
<button
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
disabled={busy || !state?.singboxRunning}
onClick={onBypassToggle}
title="Весь трафик напрямую — для диагностики"
>
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
</button>
</div>
</div>
);
}
function RecentEvents({ onNav }) {
const [entries, setEntries] = useState([]);
useEffect(() => {
let cancelled = false;
fetch('/api/logs')
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const list = (data.logs || []).slice(-15).reverse();
setEntries(list);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
return (
<div className="card">
<div className="card-header">
<h3>Последние события</h3>
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи </button>
</div>
{entries.length === 0 ? (
<small className="muted">Пока ничего нет.</small>
) : (
<div className="events-list">
{entries.slice(0, 8).map((e, i) => {
const dot = e.level === 'error' ? 'danger'
: e.level === 'warning' ? 'warning'
: 'success';
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
return (
<div key={`${e.ts}-${i}`} className="event-row">
<span className={`dot ${dot}`} />
<span className="event-time">{time}</span>
<span className="text-truncate" title={e.line}>{e.line}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
const rules = state?.customRules || [];
const enabled = rules.filter((r) => r.enabled).length;
const cacheCount = state?.directBypassCount || 0;
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn';
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<h3>Маршрутизация</h3>
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила </button>
</div>
<div className="kv-list">
<div className="row"><span className="key">Private IP</span><span className="val text-success"> direct</span></div>
{state?.routingRuDirect && (
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success"> direct</span></div>
)}
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Transparent fallback</span><span className="val"> {transparentDefault}</span></div>
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning"> {proxyDefault}</span></div>
{cacheAvailable && (
<div className="row">
<span className="key">Direct bypass cache</span>
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-success">{cacheCount} IP</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
сбросить
</button>
</span>
</div>
)}
</div>
</div>
);
}
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
return (
<div className="section-stack">
{state?.bypassMode && (
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<strong> Режим обхода правил активен</strong>
<span className="muted"> весь трафик идёт напрямую, VPN-правила не применяются.</span>
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
Отключить
</button>
</div>
)}
<StatusHero state={state} status={status} />
<div className="grid-2">
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
</div>
<RecentEvents onNav={onNav} />
</div>
);
}

View File

@@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { api } from '../api.js';
export function RouteChecker() {
const [host, setHost] = useState('');
const [port, setPort] = useState('443');
const [network, setNetwork] = useState('tcp');
const [sourceIp, setSourceIp] = useState('');
const [inbound, setInbound] = useState('tproxy-in');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
async function check() {
setBusy(true);
setError('');
setResult(null);
try {
const data = await api.route.check({
host,
port: port || undefined,
network,
sourceIp: sourceIp || undefined,
inbound,
});
setResult(data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
const r = result?.result;
const kind = r?.outbound?.startsWith('direct') ? 'success'
: r?.outbound === 'block' ? 'danger'
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
: 'warning';
return (
<div className="card flat compact">
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
<div className="filter-bar" style={{ marginTop: 12 }}>
<input
className="input"
placeholder="домен или IP (riotgames.com)"
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
style={{ minWidth: 220, flex: 1 }}
/>
<input
className="input"
placeholder="port"
value={port}
onChange={(e) => setPort(e.target.value)}
style={{ width: 90 }}
/>
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input
className="input"
placeholder="source IP"
value={sourceIp}
onChange={(e) => setSourceIp(e.target.value)}
style={{ width: 145 }}
/>
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
<option value="tproxy-in">tproxy-in</option>
<option value="mixed-in">mixed-in</option>
</select>
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div>
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
{r && (
<div className="route-result" style={{ marginTop: 12 }}>
<div className="flex-between">
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
<span className={`badge ${kind}`}> {r.outbound}</span>
</div>
{result.resolvedIp && result.resolvedFrom && (
<small className="muted text-mono">DNS: {result.resolvedFrom} {result.resolvedIp}</small>
)}
<small className="muted">{r.reason}</small>
</div>
)}
</div>
);
}

View File

@@ -1,384 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ruleTemplates } from '../templates/ruleTemplates.js';
import { ruleErrors, hasErrors } from '../utils/validation.js';
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
import { RouteChecker } from './RouteChecker.jsx';
import { api } from '../api.js';
const OUTBOUND_KIND = {
direct: { kind: 'success', label: 'direct' },
vpn: { kind: 'info', label: 'VPN' },
block: { kind: 'danger', label: 'block' },
};
const DEVICE_MODES = {
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
};
function DeviceModeSelect({ value, onChange }) {
return (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="bypass">bypass TProxy</option>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="rules">default</option>
<option value="block">block</option>
</select>
);
}
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
const devices = devicesConfig?.devices || [];
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn';
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<div>
<h2>Устройства</h2>
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы fallback после global rules.</small>
</div>
<div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}>
<span className="field-label">Transparent default</span>
<select
className="select sm"
value={defaultTransparentMode}
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="block">block</option>
</select>
</label>
<label className="field" style={{ minWidth: 160, margin: 0 }}>
<span className="field-label">Proxy default</span>
<select
className="select sm"
value={proxyDefaultMode}
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
>
<option value="vpn">VPN</option>
<option value="direct">direct</option>
<option value="block">block</option>
</select>
</label>
<button className="btn btn-primary sm" onClick={onAdd}>
+ Добавить устройство
</button>
</div>
</div>
{devices.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Название</th>
<th style={{ width: 170 }}>IP</th>
<th style={{ width: 150 }}>MAC</th>
<th style={{ width: 150 }}>Mode</th>
<th>Поведение</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{devices.map((dev) => {
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
return (
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
<td>
<input
type="checkbox"
checked={dev.enabled !== false}
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
</td>
<td>
<input
className="input sm"
value={dev.name || ''}
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
placeholder="Название устройства"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.ip || ''}
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
placeholder="192.168.1.50"
style={{ width: '100%', minWidth: 140 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.mac || ''}
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
placeholder="опционально"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
</td>
<td>
<span className={`badge ${mode.kind}`}>{mode.label}</span>
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
</td>
<td>
<button
className="btn btn-ghost sm"
onClick={() => {
if (confirm('Удалить устройство?')) onRemove(dev.id);
}}
>×</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function summary(rule) {
const parts = [];
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
if (totalDomains) parts.push(`${totalDomains} дом.`);
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
if (rule.networks?.length) parts.push(rule.networks.join('/'));
return parts.join(' · ') || '—';
}
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
return (
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
<td style={{ width: 30 }}>
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить"></span>
</td>
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
<td>
<div className="flex" style={{ alignItems: 'center' }}>
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
{rule.name || '(без названия)'}
</button>
{invalid && <span className="badge danger">ошибки</span>}
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
</div>
</td>
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
</div>
</td>
</tr>
);
}
function TemplatesModal({ open, onClose, onAdd }) {
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h3>Шаблоны маршрутизации</h3>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<div className="modal-body">
<div className="template-grid">
{ruleTemplates.map((tpl) => (
<div key={tpl.key} className="template-card">
<h4>{tpl.label}</h4>
<small>{tpl.description}</small>
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
+ Добавить
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutingPage({
rules, saveStatus, busy,
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
}) {
const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [availableRuleSets, setAvailableRuleSets] = useState([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
const t = setTimeout(() => {
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
}, 600);
return () => { cancelled = true; clearTimeout(t); };
}, [rules]);
const conflictsByRuleId = useMemo(() => {
const map = {};
for (const c of conflicts) map[c.ruleId] = c;
return map;
}, [conflicts]);
function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = rules.findIndex((r) => r.id === active.id);
const newIndex = rules.findIndex((r) => r.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(rules, oldIndex, newIndex));
}
const editing = rules.find((r) => r.id === editingId) || null;
return (
<div className="section-stack">
<RouteChecker />
<DevicesCard
devicesConfig={devicesConfig}
onDefaultsChange={onUpdateDeviceDefaults}
onAdd={onAddDevice}
onUpdate={onUpdateDevice}
onRemove={onRemoveDevice}
/>
<div className="card">
<div className="card-header">
<h2>Правила маршрутизации</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
+ Добавить
</button>
</div>
</div>
{conflicts.length > 0 && (
<div className="conflict-banner" style={{ marginBottom: 12 }}>
<span></span>
<div>
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
<div style={{ marginTop: 4 }}>
{conflicts.slice(0, 3).map((c, i) => (
<div key={i} style={{ fontSize: 12 }}>
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
</div>
))}
</div>
</div>
</div>
)}
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
Применяются <strong>сверху вниз</strong>. Перетаскивай чтобы менять порядок.
</small>
{rules.length === 0 ? (
<div className="empty-state">
<h3>Правил пока нет</h3>
<p>Добавь шаблон (например «League of Legends direct») или создай пустое правило.</p>
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
Открыть шаблоны
</button>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>Правило</th>
<th>Outbound</th>
<th>Условия</th>
<th></th>
</tr>
</thead>
<tbody>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
{rules.map((rule, i) => (
<SortableRuleRow
key={rule.id}
rule={rule}
index={i}
total={rules.length}
onEdit={setEditingId}
onUpdate={onUpdate}
onRemove={onRemove}
conflict={conflictsByRuleId[rule.id]}
/>
))}
</SortableContext>
</DndContext>
</tbody>
</table>
</div>
)}
</div>
<RuleEditorDrawer
rule={editing}
onUpdate={onUpdate}
onClose={() => setEditingId(null)}
onRemove={onRemove}
availableRuleSets={availableRuleSets}
/>
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
</div>
);
}

View File

@@ -1,453 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChipsInput } from './ChipsInput.jsx';
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
import { api } from '../api.js';
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
const RULE_SET_TAG = /^[a-z0-9][a-z0-9_.@!-]*$/i;
const validDomain = (v) => DOMAIN.test(String(v).trim());
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
const RS_PAGE_SIZE = 100;
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
const [status, setStatus] = useState('loading');
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
api.ruleSets.lookup(tag, url)
.then((d) => { setData(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}, [tag, url]);
useEffect(() => {
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [status]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, typeFilter]);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
function addEntry(entry) {
const val = entry.value;
switch (entry.type) {
case 'domain': {
const cur = new Set(rule.domains || []);
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
break;
}
case 'suffix': {
const cur = new Set(rule.domainSuffixes || []);
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
break;
}
case 'keyword': {
const cur = new Set(rule.domainKeywords || []);
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
break;
}
case 'cidr': {
const cur = new Set(rule.ipCidrs || []);
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
break;
}
default: break;
}
}
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
const addedValues = useMemo(() => new Set([
...(rule.domains || []),
...(rule.domainSuffixes || []),
...(rule.domainKeywords || []),
...(rule.ipCidrs || []),
]), [rule]);
return (
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
<div
className="modal lg"
style={{ maxWidth: 680, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">Кликните запись чтобы добавить в правило</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{status === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{status === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{status === 'done' && data && (
<>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
</div>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(RS_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '6px 0' }}>
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
<span className="muted" style={{ marginLeft: 12 }}> нажмите строку чтобы добавить в правило</span>
</div>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => {
const already = addedValues.has(e.value);
return (
<tr
key={i}
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
onClick={() => !already && addEntry(e)}
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
>
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
</tr>
);
})}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '10px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 12 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
const [view, setView] = useState(mode); // builder | json
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
const [jsonError, setJsonError] = useState('');
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
const errors = ruleErrors(rule);
// Индекс URL по тегу из доступных rule-sets
const ruleSetUrlMap = useMemo(() => {
const map = {};
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
return map;
}, [availableRuleSets]);
function patch(p) {
onUpdate(rule.id, p);
}
function applyJson() {
try {
const parsed = JSON.parse(jsonDraft);
onUpdate(rule.id, { ...parsed, id: rule.id });
setJsonError('');
} catch (err) {
setJsonError(err.message);
}
}
return (
<div className="drawer-body">
<div className="tabs">
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
</div>
{view === 'builder' ? (
<>
<div className="field">
<span className="field-label">Название</span>
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<div className="field-row">
<div className="field">
<span className="field-label">Outbound</span>
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
<option value="direct">direct (напрямую)</option>
<option value="vpn">vpn (через выбранный сервер)</option>
<option value="block">block (заблокировать)</option>
</select>
</div>
<div className="field">
<span className="field-label">Состояние</span>
<label className="checkbox">
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => patch({ enabled: e.target.checked })}
/>
Правило включено
</label>
</div>
</div>
<div className="field">
<span className="field-label">Rule-sets (geo-базы)</span>
<ChipsInput
value={rule.ruleSets || []}
onChange={(v) => patch({ ruleSets: v })}
placeholder="geosite-runet"
validate={validRuleSetTag}
/>
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
{(rule.ruleSets || []).length > 0 && (
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{(rule.ruleSets || []).map((tag) => {
const url = ruleSetUrlMap[tag];
return url ? (
<button
key={tag}
className="btn btn-ghost sm"
style={{ padding: '0 6px', fontSize: 11 }}
onClick={() => setBrowseTag({ tag, url })}
title={`Просмотреть содержимое ${tag}`}
>
🔍 {tag}
</button>
) : null;
})}
</div>
)}
{availableRuleSets.length > 0 && (
<div className="field-hint">
Доступны:{' '}
{availableRuleSets.map((rs) => (
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
<button
className="btn btn-ghost sm"
style={{ padding: '0 6px', marginRight: 2 }}
onClick={() => {
const current = new Set(rule.ruleSets || []);
if (!current.has(rs.tag)) {
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
}
}}
>
+ {rs.tag}
</button>
<button
className="btn btn-ghost sm"
style={{ padding: '0 4px', fontSize: 12 }}
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
title="Просмотреть содержимое"
>
🔍
</button>
</span>
))}
</div>
)}
{availableRuleSets.length === 0 && (
<span className="field-hint">
Настройте rule-sets в Настройках, затем вводите их теги здесь
</span>
)}
</div>
<div className="field">
<span className="field-label">Домены (точное совпадение)</span>
<ChipsInput
value={rule.domains || []}
onChange={(v) => patch({ domains: v })}
placeholder="riotgames.com"
validate={validDomain}
/>
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Суффиксы доменов</span>
<ChipsInput
value={rule.domainSuffixes || []}
onChange={(v) => patch({ domainSuffixes: v })}
placeholder="riotcdn.net"
validate={validDomain}
/>
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">IP / CIDR</span>
<ChipsInput
value={rule.ipCidrs || []}
onChange={(v) => patch({ ipCidrs: v })}
placeholder="104.160.128.0/19"
validate={isValidCidr}
/>
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
<ChipsInput
value={rule.ports || []}
onChange={(v) => patch({ ports: v })}
placeholder="443"
validate={(p) => {
const s = String(p);
if (s.includes('-')) {
const [a, b] = s.split('-');
return isValidPort(a) && isValidPort(b);
}
return isValidPort(p);
}}
/>
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Протоколы</span>
<div className="flex">
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('tcp') : set.delete('tcp');
patch({ networks: Array.from(set) });
}}
/>
TCP
</label>
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('udp') : set.delete('udp');
patch({ networks: Array.from(set) });
}}
/>
UDP
</label>
<span className="field-hint">Если ничего оба</span>
</div>
</div>
</>
) : (
<>
<div className="field">
<span className="field-label">Сырой JSON правила</span>
<textarea
className="textarea"
style={{ minHeight: 320 }}
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
/>
{jsonError && <span className="field-error">{jsonError}</span>}
</div>
<div className="btn-group">
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
</div>
</>
)}
{browseTag && (
<RuleSetBrowseModal
tag={browseTag.tag}
url={browseTag.url}
rule={rule}
onPatch={(p) => patch(p)}
onClose={() => setBrowseTag(null)}
/>
)}
</div>
);
}
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
if (!rule) return null;
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className="drawer">
<div className="drawer-head">
<div>
<h3>Редактирование правила</h3>
<small className="muted">{rule.name || '(без названия)'}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
<div className="drawer-foot">
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
<div className="btn-group">
{invalid && <span className="badge danger">Есть ошибки</span>}
<button className="btn btn-primary" onClick={onClose}>Готово</button>
</div>
</div>
</aside>
</>
);
}

View File

@@ -1,210 +0,0 @@
import React, { useMemo, useState } from 'react';
import { api } from '../api.js';
import { flagFor } from '../utils/country.js';
import { formatRelative } from '../utils/format.js';
function PingCell({ ping }) {
if (!ping) return <span className="muted"></span>;
if (ping.checking) return <span className="badge neutral pulse">проверяем</span>;
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
const ms = ping.latency;
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
return <span className={`badge ${kind}`}>{ms} ms</span>;
}
function StatusCell({ ping }) {
if (!ping) return <span className="badge neutral">unknown</span>;
if (ping.checking) return <span className="badge neutral pulse"></span>;
return ping.ok
? <span className="badge success"> online</span>
: <span className="badge danger"> offline</span>;
}
export function ServersPage({
state,
servers,
selectedTag,
setSelectedTag,
pendingTag,
setPendingTag,
busy,
onApply,
onRollback,
pings,
setPings,
pushToast,
}) {
const [filter, setFilter] = useState('all'); // all | online
const [search, setSearch] = useState('');
async function pingOne(server) {
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
try {
const res = await api.servers.ping(server.server, server.server_port);
setPings((prev) => ({
...prev,
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
}));
} catch (err) {
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
}
}
async function pingAll() {
setPings((prev) => {
const next = { ...prev };
for (const s of servers) next[s.tag] = { checking: true };
return next;
});
try {
const res = await api.servers.pingAll();
const map = {};
for (const r of res.results || []) {
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
}
setPings((prev) => ({ ...prev, ...map }));
pushToast({ kind: 'success', title: 'Пинг завершён' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
}
}
const filtered = useMemo(() => {
return servers.filter((s) => {
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
return false;
}
if (filter === 'online' && !pings[s.tag]?.ok) return false;
return true;
});
}, [servers, search, filter, pings]);
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
const pendingServer = servers.find((s) => s.tag === pendingTag);
if (!servers.length) {
return (
<div className="card">
<div className="empty-state">
<h3>Серверы ещё не загружены</h3>
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
</div>
</div>
);
}
return (
<div className="section-stack">
{pendingDifferent && (
<div className="card" style={{ borderColor: 'var(--warning)' }}>
<div className="flex-between">
<div>
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
</div>
</div>
<div className="btn-group">
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
Отменить
</button>
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
Применить изменения
</button>
</div>
</div>
</div>
)}
<div className="card">
<div className="card-header">
<h2>Серверы ({servers.length})</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
Проверить все
</button>
{state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
Откатить ({state.previousTag})
</button>
)}
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тегу или хосту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все</option>
<option value="online">Только online</option>
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 16 }}></th>
<th>Сервер</th>
<th>Хост</th>
<th>Тип</th>
<th>Ping</th>
<th>Статус</th>
<th style={{ textAlign: 'right' }}>Действие</th>
</tr>
</thead>
<tbody>
{filtered.map((server) => {
const isActive = server.tag === state?.selectedTag;
const isPending = server.tag === pendingTag && !isActive;
const ping = pings[server.tag];
return (
<tr key={server.tag} className={isActive ? 'active' : ''}>
<td>{flagFor(server)}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong>{server.tag}</strong>
{isActive && <span className="badge success">ACTIVE</span>}
{isPending && <span className="badge warning">pending</span>}
</div>
</td>
<td className="text-mono muted">{server.server}:{server.server_port}</td>
<td><span className="badge neutral">{server.type}</span></td>
<td><PingCell ping={ping} /></td>
<td><StatusCell ping={ping} /></td>
<td>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
Ping
</button>
{isActive ? (
<button className="btn btn-secondary sm" disabled>Активен</button>
) : (
<button
className="btn btn-primary sm"
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
disabled={busy}
>
Выбрать
</button>
)}
</div>
</td>
</tr>
);
})}
{!filtered.length && (
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,784 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { formatRelative } from '../utils/format.js';
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
const PAGE_SIZE = 100;
function RuleSetLookupModal({ tag, url, onClose }) {
const [state, setState] = useState('idle'); // idle | loading | done | error
const [error, setError] = useState('');
const [data, setData] = useState(null); // { entries, stats, cachedAt }
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
setState('loading');
api.ruleSets.lookup(tag, url)
.then((res) => { setData(res); setState('done'); })
.catch((err) => { setError(err.message); setState('error'); });
}, [tag, url]);
useEffect(() => {
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [state]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (filterType !== 'all' && e.type !== filterType) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, filterType]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setFilterType(v); setPage(0); }
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">{url}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{state === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{state === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{state === 'done' && data && (
<>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
кеш: {formatRelative(data.cachedAt)}
</span>
</div>
</div>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
</div>
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => (
<tr key={i}>
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
// Каталог готовых rule-set источников для sing-box (.srs формат)
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
const RULE_SET_CATALOG = [
{
tag: 'geosite-runet',
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
source: 'runetfreedom',
category: 'RU',
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
builtIn: false,
},
{
tag: 'geoip-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
source: 'SagerNet/sing-geoip',
category: 'RU',
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
use: 'direct — российские сервисы без VPN.',
builtIn: true,
},
{
tag: 'geosite-category-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
source: 'SagerNet/sing-geosite',
category: 'RU',
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
builtIn: true,
},
{
tag: 'geosite-google',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
builtIn: false,
},
{
tag: 'geosite-youtube',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
use: 'vpn — для разблокировки YouTube.',
builtIn: false,
},
{
tag: 'geosite-telegram',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
builtIn: false,
},
{
tag: 'geosite-openai',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
builtIn: false,
},
{
tag: 'geosite-apple',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
builtIn: false,
},
{
tag: 'geosite-github',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
source: 'SagerNet/sing-geosite',
category: 'Разработка',
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
use: 'vpn — если GitHub замедлен или заблокирован.',
builtIn: false,
},
{
tag: 'geosite-category-ads-all',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
source: 'SagerNet/sing-geosite',
category: 'Блокировка',
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
builtIn: false,
},
];
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
const [editing, setEditing] = useState(!state?.hasSubscription);
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
const masked = state?.hasSubscription && !editing;
return (
<div className="card">
<div className="card-header">
<h2>Подписка</h2>
{state?.hasSubscription && (
<span className="badge success"> активна</span>
)}
</div>
{masked ? (
<div className="kv-list">
<div className="row">
<span className="key">URL</span>
<span className="val text-mono">{state.subscriptionHost}</span>
</div>
<div className="row">
<span className="key">Серверов</span>
<span className="val">{state.servers?.length || 0}</span>
</div>
<div className="row">
<span className="key">Загружено</span>
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
</div>
</div>
) : (
<div className="field">
<span className="field-label">Subscription URL</span>
<div className="subscription-input">
<input
className="input"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder="https://provider.example/sub/..."
/>
</div>
</div>
)}
<div className="btn-group" style={{ marginTop: 16 }}>
{masked ? (
<>
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}> Обновить серверы</button>
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
</>
) : (
<>
<button
className="btn btn-primary"
onClick={async () => { await onFetch(); setEditing(false); }}
disabled={busy || !subscriptionUrl}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
</button>
{state?.hasSubscription && (
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
)}
</>
)}
</div>
</div>
);
}
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
const [validation, setValidation] = useState(null);
const [validating, setValidating] = useState(false);
async function validate() {
setValidating(true);
try {
const data = await api.configValidate();
setValidation(data);
pushToast({
kind: data.valid ? 'success' : 'danger',
title: data.valid ? 'Config валиден' : 'Config невалиден',
message: data.error || data.note,
});
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
} finally {
setValidating(false);
}
}
return (
<div className="card">
<div className="card-header">
<h2>sing-box config</h2>
{validation && (
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
{validation.valid ? '✓ валиден' : '✗ ошибка'}
</span>
)}
</div>
<div className="kv-list">
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
</div>
<div className="btn-group" style={{ marginTop: 16 }}>
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
{validating ? 'Проверяем…' : '✓ Валидировать'}
</button>
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
Сбросить config
</button>
</div>
{validation && !validation.valid && validation.error && (
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
<span></span><div>{validation.error}</div>
</div>
)}
</div>
);
}
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
const [open, setOpen] = useState(false);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1 }}>
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
{entry.builtIn && (
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
)}
</div>
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
</div>
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
<button
className="btn btn-ghost sm"
onClick={() => setOpen((o) => !o)}
title="Примеры и подсказка"
>
{open ? '▲' : '▼'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => onLookup(entry)}
title="Просмотреть содержимое и искать внутри"
>
🔍
</button>
<button
className="btn btn-secondary sm"
disabled={busy || added}
onClick={() => onAdd(entry)}
>
{added ? '✓ добавлен' : '+ Добавить'}
</button>
</div>
</div>
{open && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<span className="muted">Примеры содержимого: </span>
{entry.examples.map((ex, i) => (
<span key={i}>
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
{i < entry.examples.length - 1 ? ' ' : ''}
</span>
))}
</div>
<div style={{ fontSize: 12 }}>
<span className="muted">Рекомендуемый outbound: </span>
<span>{entry.use}</span>
</div>
</div>
)}
</div>
);
}
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState('idle'); // idle | loading | done | error
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
function load() {
if (status !== 'idle') return;
setStatus('loading');
api.ruleSets.sagernetCatalog()
.then((d) => { setCatalog(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}
function toggle() {
if (!open && status === 'idle') load();
setOpen((o) => !o);
}
const results = useMemo(() => {
if (!catalog) return [];
const q = query.trim().toLowerCase();
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
const all = [...gs, ...gi];
if (!q) return all;
return all.filter((item) => item.name.includes(q));
}, [catalog, query, repoFilter]);
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
return (
<div className="card">
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
<h2>Поиск в каталоге SagerNet</h2>
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
{status === 'done' && catalog && (
<span className="badge info" style={{ fontSize: 11 }}>
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
</span>
)}
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
</div>
</div>
{open && (
<>
{status === 'loading' && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
Загрузка списка из GitHub
</div>
)}
{status === 'error' && (
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
<span></span><div>{error}</div>
</div>
)}
{status === 'done' && (
<>
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
Кеш обновляется раз в 24 ч.
</small>
{catalog.fallback && (
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
</div>
)}
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="steam, gaming, netflix, cloudflare…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
<option value="all">geosite + geoip</option>
<option value="geosite">только geosite</option>
<option value="geoip">только geoip</option>
</select>
</div>
{query.trim() === '' ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
Введите запрос покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
</div>
) : results.length === 0 ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
) : (
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr>
<th style={{ width: 60 }}>Тип</th>
<th>Тег</th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{results.slice(0, 100).map((item) => (
<tr key={item.name}>
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
<td className="text-mono">{item.name}</td>
<td style={{ textAlign: 'right' }}>
{addedTags.has(item.name) ? (
<span className="badge success" style={{ fontSize: 11 }}> добавлен</span>
) : (
<button
className="btn btn-secondary sm"
disabled={busy}
onClick={() => onAdd({ tag: item.name, url: item.url })}
>
+ Добавить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{results.length > 100 && (
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
Показано 100 из {results.length} уточните запрос
</div>
)}
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
</div>
</>
)}
</>
)}
</div>
);
}
function RuleSetsCard({ pushToast }) {
const [ruleSets, setRuleSets] = useState([]);
const [newTag, setNewTag] = useState('');
const [newUrl, setNewUrl] = useState('');
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('Все');
const [lookup, setLookup] = useState(null); // { tag, url }
useEffect(() => {
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
}, []);
async function save(next) {
setBusy(true);
try {
const data = await api.ruleSets.save(next);
setRuleSets(data.ruleSets || []);
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
} finally {
setBusy(false);
}
}
function addNew() {
const tag = newTag.trim();
const url = newUrl.trim();
if (!tag || !url) return;
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
return;
}
if (ruleSets.some((rs) => rs.tag === tag)) {
pushToast({ kind: 'danger', title: 'Тег уже существует' });
return;
}
const next = [...ruleSets, { tag, url }];
setNewTag('');
setNewUrl('');
save(next);
}
function remove(tag) {
save(ruleSets.filter((rs) => rs.tag !== tag));
}
function addFromCatalog(entry) {
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
return;
}
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
}
const q = search.trim().toLowerCase();
const filtered = RULE_SET_CATALOG.filter((entry) => {
if (category !== 'Все' && entry.category !== category) return false;
if (!q) return true;
return (
entry.tag.includes(q) ||
entry.description.toLowerCase().includes(q) ||
entry.source.toLowerCase().includes(q) ||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
);
});
return (
<>
<div className="card">
<div className="card-header">
<h2>Источники (rule-sets)</h2>
</div>
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
</small>
{ruleSets.length > 0 && (
<>
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
<table className="table" style={{ marginBottom: 20 }}>
<thead>
<tr>
<th>Тег</th>
<th>URL</th>
<th></th>
</tr>
</thead>
<tbody>
{ruleSets.map((rs) => (
<tr key={rs.tag}>
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="Поиск: telegram, реклама, youtube…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
{filtered.length === 0 && (
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
)}
{filtered.map((entry) => (
<CatalogEntry
key={entry.tag}
entry={entry}
added={ruleSets.some((rs) => rs.tag === entry.tag)}
busy={busy}
onAdd={addFromCatalog}
onLookup={(e) => setLookup(e)}
/>
))}
<div className="field" style={{ marginTop: 16 }}>
<span className="field-label">Добавить свой .srs</span>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
<input
className="input"
style={{ width: 200 }}
placeholder="тег (напр. geosite-custom)"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
/>
<input
className="input"
style={{ flex: 1, minWidth: 200 }}
placeholder="https://…/rule-set.srs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
Добавить
</button>
</div>
</div>
</div>
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
{lookup && (
<RuleSetLookupModal
tag={lookup.tag}
url={lookup.url}
onClose={() => setLookup(null)}
/>
)}
</>
);
}
function PortsCard({ state }) {
const isClient = state?.mode === 'client';
return (
<div className="card">
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
<div className="kv-list">
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
</div>
</div>
);
}
export function SettingsPage({
state, subscriptionUrl, setSubscriptionUrl, busy,
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
}) {
return (
<div className="section-stack">
<SubscriptionCard
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetch={onFetchSubscription}
onForget={onForgetSubscription}
pushToast={pushToast}
/>
<ConfigCard
state={state}
busy={busy}
onShowConfig={onShowConfig}
onClearConfig={onClearConfig}
pushToast={pushToast}
/>
<RuleSetsCard pushToast={pushToast} />
<PortsCard state={state} />
</div>
);
}

View File

@@ -1,37 +0,0 @@
import React from 'react';
const NAV = [
{ id: 'overview', label: 'Обзор', ico: '◉' },
{ id: 'servers', label: 'Серверы', ico: '⋆' },
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
{ id: 'logs', label: 'Логи', ico: '≡' },
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
const items = mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
return (
<nav className="sidebar">
{items.map((item) => {
const badge = badges[item.id];
return (
<button
key={item.id}
type="button"
className={`sidebar-item${active === item.id ? ' active' : ''}`}
onClick={() => onChange(item.id)}
>
<span className="ico">{item.ico}</span>
{item.label}
{badge && (
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
)}
</button>
);
})}
</nav>
);
}

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
function StatusRow({ label, value, kind }) {
return (
<div className="row">
<span className="key">{label}</span>
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
</div>
);
}
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
: '—';
let singboxStatus = 'Остановлен';
let singboxKind = 'muted';
if (state?.singboxRunning) {
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
singboxKind = 'success';
} else if (state?.configExists) {
singboxStatus = 'остановлен (конфиг есть)';
singboxKind = 'warning';
}
return (
<aside className="status-pane">
<div className="card compact flat">
<div className="card-header no-margin">
<h3>sing-box</h3>
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
{state?.singboxRunning ? '● online' : '○ offline'}
</span>
</div>
<div className="kv-list" style={{ marginTop: 12 }}>
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
<StatusRow label="Mixed proxy" value={`${state?.proxyBindIp || '0.0.0.0'}:${state?.proxyPort || 8080}`} />
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
<StatusRow label="Трафик" value={traffic} />
<StatusRow
label="Применено"
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
/>
</div>
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<button
className="btn btn-secondary sm block"
disabled={busy || !state?.configExists}
onClick={onRestart}
>
Перезапустить
</button>
<button
className="btn btn-ghost sm block"
disabled={busy || !state?.singboxRunning}
onClick={onStop}
>
Остановить
</button>
<button
className="btn btn-ghost sm block"
disabled={!state?.configExists}
onClick={onShowConfig}
>
Показать config
</button>
</div>
</div>
{state?.appliedHistory?.length > 0 && (
<div className="card compact flat">
<h4 style={{ marginBottom: 8 }}>История применений</h4>
<div className="events-list">
{state.appliedHistory.slice(0, 5).map((h) => (
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
<span className="text-truncate">{h.tag}</span>
<span className="event-time">{formatRelative(h.at)}</span>
</div>
))}
</div>
</div>
)}
</aside>
);
}

View File

@@ -1,32 +0,0 @@
import React, { useEffect } from 'react';
export function Toasts({ items, onDismiss }) {
useEffect(() => {
const timers = items.map((t) =>
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
);
return () => timers.forEach((t) => t && clearTimeout(t));
}, [items, onDismiss]);
if (!items.length) return null;
return (
<div className="toasts">
{items.map((t) => (
<div key={t.id} className={`toast ${t.kind || ''}`}>
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
<div className="body">
<strong>{t.title}</strong>
{t.message && <small>{t.message}</small>}
{t.action && (
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
{t.action.label}
</button>
)}
</div>
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
</div>
))}
</div>
);
}

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
function StatusBadge({ status }) {
const map = {
running: { dot: 'success', text: 'Работает', cls: '' },
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
error: { dot: 'danger', text: 'Ошибка', cls: '' },
stopped: { dot: '', text: 'Остановлен', cls: '' },
no_config: { dot: '', text: 'Не настроен', cls: '' },
};
const cfg = map[status] || map.stopped;
return (
<span className="flex">
<span className={`dot ${cfg.dot}`} />
<strong>{cfg.text}</strong>
</span>
);
}
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: null;
const isClient = state?.mode === 'client';
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
</div>
<div className="topbar-status">
<StatusBadge status={status} />
{activeServer && (
<div className="status-text">
<strong>
{flagFor(activeServer)} {activeServer.tag}
</strong>
<small>
{activeServer.server}:{activeServer.server_port}
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
</small>
</div>
)}
{!activeServer && (
<small className="muted">Сервер не выбран</small>
)}
{traffic && <span className="badge neutral">{traffic}</span>}
</div>
<div className="topbar-actions">
{!isClient && dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{!isClient && state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
)}
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Перезапуск
</button>
</div>
</header>
);
}

View File

@@ -1,843 +1,54 @@
/* Системные шрифты — без загрузки из интернета */
:root {
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, sans-serif;
--font-head: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Cascadia Code', Consolas, 'Liberation Mono', Menlo, monospace;
color-scheme: dark;
/* Surfaces */
--bg: #06110d;
--surface: #0b1b14;
--surface-2: #10251b;
--surface-3: #163224;
--border: #214734;
--border-strong: #2c5d44;
/* Text */
--text: #effff5;
--muted: #9fbaaa;
--subtle: #6f8c7c;
/* Roles */
--success: #6dff9d;
--success-dim: rgba(109, 255, 157, 0.14);
--warning: #ffd166;
--warning-dim: rgba(255, 209, 102, 0.14);
--danger: #ff5c5c;
--danger-dim: rgba(255, 92, 92, 0.14);
--accent: #b7ff63;
--accent-dim: rgba(183, 255, 99, 0.14);
--info: #8ed4ff;
--info-dim: rgba(142, 212, 255, 0.14);
/* Radii */
--radius-card: 20px;
--radius-input: 12px;
--radius-pill: 999px;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Shadow */
--shadow-card: 0 12px 36px rgba(0, 0, 0, 0.32);
--shadow-modal: 0 32px 80px rgba(0, 0, 0, 0.6);
/* Layout */
--topbar-h: 64px;
--sidebar-w: 220px;
--status-w: 280px;
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
button,
input {
font: inherit;
color: inherit;
}
h2,
p {
margin: 0;
}
.app {
min-height: 100vh;
display: grid;
}
.app-body {
min-height: 0;
}
.app-main {
min-width: 0;
}
.app-loading {
min-height: 100%;
min-height: 100vh;
display: grid;
place-items: center;
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
color-scheme: light dark;
}
body {
margin: 0;
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 {
font-family: var(--font-head);
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 { font-size: 22px; }
h2 { font-size: 18px; }
h3 { font-size: 15px; color: var(--text); }
h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
p { margin: 0; }
small { font-size: 12px; color: var(--muted); }
code, .mono {
font-family: var(--font-mono);
font-size: 12px;
}
/* ============ Layout ============ */
.app {
min-height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
background:
radial-gradient(circle at 8% 0%, rgba(109, 255, 157, 0.05), transparent 32rem),
radial-gradient(circle at 92% 100%, rgba(142, 212, 255, 0.04), transparent 28rem),
var(--bg);
}
.app-body {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
min-height: 0;
}
.app-body.client-mode {
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 {
padding: var(--space-6);
overflow-y: auto;
min-width: 0;
}
@media (max-width: 1100px) {
.app-body { grid-template-columns: var(--sidebar-w) 1fr; }
.status-pane { display: none; }
}
@media (max-width: 768px) {
.app-body { grid-template-columns: 1fr; }
.app-body.client-mode { grid-template-columns: 1fr; }
.sidebar { display: none; }
.app-main { padding: var(--space-4); }
}
/* ============ Topbar ============ */
.topbar {
display: flex;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-6);
background: var(--surface);
border-bottom: 1px solid var(--border);
height: var(--topbar-h);
}
.topbar-brand {
font-family: var(--font-head);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: var(--space-2);
}
.topbar-brand .logo-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
}
.topbar-status {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.topbar-status .status-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.topbar-status .status-text strong { font-weight: 600; font-size: 13px; }
.topbar-status .status-text small { font-size: 11px; color: var(--subtle); }
.topbar-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ============ Sidebar ============ */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: var(--space-4) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px 12px;
border-radius: var(--radius-input);
color: var(--muted);
cursor: pointer;
border: 1px solid transparent;
background: transparent;
text-align: left;
font-weight: 500;
font-size: 14px;
transition: background 0.12s, color 0.12s;
}
.sidebar-item:hover {
background: var(--surface-2);
color: var(--text);
}
.sidebar-item.active {
background: var(--surface-2);
color: var(--text);
border-color: var(--border);
}
.sidebar-item .ico { width: 18px; opacity: 0.85; flex: 0 0 18px; }
.sidebar-item .badge {
margin-left: auto;
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--surface-3);
color: var(--muted);
}
.sidebar-item .badge.warn { background: var(--warning-dim); color: var(--warning); }
.sidebar-item .badge.danger { background: var(--danger-dim); color: var(--danger); }
/* ============ Status pane ============ */
.status-pane {
background: var(--surface);
border-left: 1px solid var(--border);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
overflow-y: auto;
}
/* ============ Cards & panels ============ */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.card.compact { padding: var(--space-4); }
.card.flat { box-shadow: none; background: var(--surface-2); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.card-header.no-margin { margin-bottom: 0; }
.section-stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
@media (max-width: 900px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
/* ============ Buttons ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-input);
padding: 8px 14px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
background: transparent;
color: var(--text);
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
white-space: nowrap;
line-height: 1.2;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.lg { padding: 12px 18px; font-size: 14px; }
.btn.sm { padding: 6px 10px; font-size: 12px; }
.btn.block { width: 100%; }
.btn-primary {
background: var(--accent);
color: #061608;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
.btn-secondary {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--muted);
}
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border-strong); }
.btn-danger {
background: transparent;
border-color: var(--danger);
color: var(--danger);
}
.btn-danger:hover:not(:disabled) { background: var(--danger-dim); }
.btn-link {
background: transparent;
border: none;
color: var(--accent);
padding: 4px 6px;
}
.btn-link:hover:not(:disabled) { text-decoration: underline; }
.btn-group {
display: inline-flex;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ============ Inputs ============ */
.input, .select, .textarea {
display: block;
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: 9px 12px;
color: var(--text);
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.textarea { min-height: 80px; resize: vertical; font-family: var(--font-mono); font-size: 12px; }
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.field-error { color: var(--danger); font-size: 11px; }
.field-hint { color: var(--subtle); font-size: 11px; }
.checkbox {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
user-select: none;
font-size: 13px;
color: var(--muted);
}
.checkbox input { width: 14px; height: 14px; accent-color: var(--accent); }
/* ============ Badges & dots ============ */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
background: var(--surface-3);
color: var(--muted);
border: 1px solid var(--border);
line-height: 1.2;
white-space: nowrap;
}
.badge.success { background: var(--success-dim); color: var(--success); border-color: rgba(109, 255, 157, 0.3); }
.badge.warning { background: var(--warning-dim); color: var(--warning); border-color: rgba(255, 209, 102, 0.3); }
.badge.danger { background: var(--danger-dim); color: var(--danger); border-color: rgba(255, 92, 92, 0.3); }
.badge.info { background: var(--info-dim); color: var(--info); border-color: rgba(142, 212, 255, 0.3); }
.badge.neutral { background: var(--surface-3); color: var(--muted); }
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--subtle);
flex: 0 0 8px;
}
.dot.success { background: var(--success); box-shadow: 0 0 8px var(--success); }
.dot.warning { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
.dot.danger { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.dot.info { background: var(--info); box-shadow: 0 0 8px var(--info); }
.pulse {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============ Lists & tables ============ */
.kv-list {
display: flex;
flex-direction: column;
}
.kv-list .row {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.kv-list .row:last-child { border-bottom: 0; }
.kv-list .key { color: var(--muted); }
.kv-list .val { color: var(--text); font-weight: 500; }
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th, .table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 600;
}
.table tbody tr { transition: background 0.1s; }
.table tbody tr:hover { background: var(--surface-2); }
.table tbody tr.active { background: var(--accent-dim); }
.table .row-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
/* ============ Tag input (chips) ============ */
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
min-height: 40px;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
}
.chip.error { border-color: var(--danger); color: var(--danger); }
.chip button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.chip button:hover { color: var(--danger); }
.chips .chip-input {
flex: 1;
min-width: 100px;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 6px;
}
/* ============ Sticky action bar ============ */
.sticky-bar {
position: sticky;
bottom: 0;
margin: var(--space-6) calc(-1 * var(--space-6)) calc(-1 * var(--space-6));
padding: var(--space-3) var(--space-6);
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
z-index: 5;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
}
/* ============ Drawer & modal ============ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 5, 0.65);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-modal);
width: min(720px, 100%);
max-height: 86vh;
display: flex;
flex-direction: column;
}
.modal.lg { width: min(960px, 100%); }
.modal-head, .modal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.modal-foot { border-top: 1px solid var(--border); border-bottom: 0; }
.modal-body { padding: var(--space-5); overflow-y: auto; flex: 1; }
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 5, 0.55);
backdrop-filter: blur(3px);
z-index: 90;
}
.drawer {
position: fixed;
top: 0; right: 0; bottom: 0;
width: min(540px, 100%);
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-modal);
z-index: 91;
display: flex;
flex-direction: column;
}
.drawer-head, .drawer-foot {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.drawer-foot { border-top: 1px solid var(--border); border-bottom: 0; }
.drawer-body { padding: var(--space-5); overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: var(--space-4); }
/* ============ Toasts ============ */
.toasts {
position: fixed;
top: calc(var(--topbar-h) + 12px);
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
max-width: 360px;
}
.toast {
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-start;
box-shadow: var(--shadow-card);
font-size: 13px;
}
.toast.success { border-color: rgba(109, 255, 157, 0.3); }
.toast.danger { border-color: rgba(255, 92, 92, 0.3); }
.toast.warning { border-color: rgba(255, 209, 102, 0.3); }
.toast .body { flex: 1; }
.toast .body small { display: block; color: var(--muted); margin-top: 2px; }
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; }
/* ============ Logs ============ */
.logs-stream {
background: #02080a;
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.55;
overflow-y: auto;
flex: 1;
min-height: 320px;
}
.log-line {
display: grid;
grid-template-columns: 70px 60px 1fr;
gap: 10px;
padding: 2px 4px;
border-radius: 4px;
word-break: break-word;
}
.log-line:hover { background: var(--surface-2); }
.log-line .log-time { color: var(--subtle); }
.log-line .log-level { font-size: 10px; text-transform: uppercase; padding-top: 2px; font-weight: 600; }
.log-line.info .log-level { color: var(--info); }
.log-line.warning .log-level { color: var(--warning); }
.log-line.error .log-level { color: var(--danger); }
.log-line.debug .log-level { color: var(--subtle); }
.log-line .log-text { color: var(--text); }
.log-group {
display: grid;
grid-template-columns: 70px 60px 1fr auto;
gap: 10px;
padding: 4px;
border-radius: 4px;
background: var(--surface-2);
margin-bottom: 4px;
align-items: center;
}
.log-group .repeat { color: var(--warning); font-size: 11px; }
/* ============ Misc ============ */
.empty-state {
text-align: center;
padding: var(--space-8) var(--space-4);
color: var(--muted);
}
.empty-state h3 { margin-bottom: var(--space-2); color: var(--text); }
.flex { display: flex; gap: var(--space-3); align-items: center; }
.flex-col { display: flex; flex-direction: column; gap: var(--space-3); }
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; min-width: 0; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
.text-mono { font-family: var(--font-mono); }
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.divider {
height: 1px;
background: var(--border);
margin: var(--space-3) 0;
}
.config-view {
background: #02080a;
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
margin: 0;
color: var(--text);
overflow: auto;
max-height: 60vh;
white-space: pre;
}
/* drag handle for rules sortable */
.drag-handle {
cursor: grab;
user-select: none;
padding: 6px 8px;
border-radius: 6px;
color: var(--subtle);
font-family: var(--font-mono);
font-size: 14px;
}
.drag-handle:hover { color: var(--text); background: var(--surface-2); }
.drag-handle:active { cursor: grabbing; }
.rule-row.disabled { opacity: 0.5; }
.rule-row.invalid td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
.conflict-banner {
background: var(--warning-dim);
border: 1px solid rgba(255, 209, 102, 0.3);
color: var(--warning);
padding: 10px 14px;
border-radius: var(--radius-input);
font-size: 12px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.conflict-banner.danger { background: var(--danger-dim); border-color: rgba(255, 92, 92, 0.3); color: var(--danger); }
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-3);
}
.template-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: 6px;
}
.template-card h4 { color: var(--text); text-transform: none; letter-spacing: 0; font-size: 13px; }
.template-card small { color: var(--subtle); font-size: 11px; line-height: 1.4; }
.events-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.event-row {
display: grid;
grid-template-columns: 16px 60px 1fr;
gap: 10px;
align-items: center;
padding: 6px 8px;
border-radius: 8px;
font-size: 12px;
}
.event-row:hover { background: var(--surface-2); }
.event-row .event-time { color: var(--subtle); font-family: var(--font-mono); font-size: 11px; }
.route-result {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-4);
}
.tab {
padding: 8px 14px;
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab:hover { color: var(--text); }
.filter-bar {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.filter-bar .input, .filter-bar .select { width: auto; min-width: 140px; }
.subscription-input {
display: flex;
gap: var(--space-2);
align-items: stretch;
}
.subscription-input .input { flex: 1; }
/* ============ Client overview ============ */
.app.client-app {
@@ -1037,6 +248,7 @@ code, .mono {
.client-form {
position: absolute;
left: calc(50% + 130px);
min-width: 0;
width: min(360px, calc(50% - 154px));
display: grid;
gap: 36px;
@@ -1046,6 +258,7 @@ code, .mono {
.client-subscription {
position: relative;
min-width: 0;
min-height: 88px;
display: grid;
align-items: center;
@@ -1246,6 +459,9 @@ code, .mono {
}
.client-subscription-domain-button {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -1255,6 +471,8 @@ code, .mono {
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
@@ -1265,6 +483,7 @@ code, .mono {
color: var(--client-muted);
text-align: center;
cursor: pointer;
overflow: hidden;
}
.client-subscription.is-editing .client-subscription-summary {
@@ -1281,6 +500,8 @@ code, .mono {
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 28px;
font-weight: 600;
@@ -1288,6 +509,8 @@ code, .mono {
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);
text-overflow: ellipsis;
white-space: nowrap;
}
.client-subscription-domain-button:hover strong {
@@ -1520,12 +743,33 @@ code, .mono {
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
}
.client-proxies > div {
.client-access-point {
display: grid;
justify-items: center;
gap: 5px;
}
.client-access-point + .client-access-point {
margin-top: 12px;
}
.client-proxy-actions {
display: flex;
gap: 8px;
margin-top: 7px;
}
.client-error {
position: absolute;
bottom: 0;
left: 50%;
width: min(420px, 90vw);
color: oklch(0.62 0.2 28);
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: center;
transform: translateX(-50%);
}
.client-proxies button {
position: relative;
width: 86px;
@@ -1648,11 +892,3 @@ code, .mono {
animation: none;
}
}
/* For drawer rule editor */
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
@media (max-width: 600px) { .field-row { grid-template-columns: 1fr; } }

View File

@@ -1,127 +0,0 @@
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
let counter = 0;
function id(prefix) {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
function template(name, outbound, fields) {
return {
id: id("tpl"),
name,
enabled: true,
outbound,
domains: [],
domainSuffixes: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
...fields,
};
}
export const ruleTemplates = [
{
key: "lol-direct",
label: "League of Legends → direct",
description: "Riot/LoL домены и порты — играть напрямую без VPN.",
build: () =>
template("League of Legends", "direct", {
domainSuffixes: [
"leagueoflegends.com",
"riotgames.com",
"riotcdn.net",
"dyn.riotcdn.net",
],
ports: ["5000", "5223", "5222", "8088"],
}),
},
{
key: "discord-direct",
label: "Discord/Vesktop → direct",
description: "Discord voice/video и WebSocket напрямую.",
build: () =>
template("Discord", "direct", {
domainSuffixes: [
"discord.com",
"discord.gg",
"discord.media",
"discordapp.com",
"discordapp.net",
],
ports: ["50000-65535"],
networks: ["udp"],
}),
},
{
key: "telegram-vpn",
label: "Telegram → VPN",
description: "Telegram через выбранный VPN outbound.",
build: () =>
template("Telegram", "vpn", {
domainSuffixes: [
"telegram.org",
"t.me",
"telegram.me",
"telegra.ph",
"tdesktop.com",
],
ipCidrs: [
"149.154.160.0/20",
"91.108.4.0/22",
"91.108.8.0/22",
"91.108.12.0/22",
"91.108.16.0/22",
"91.108.56.0/22",
],
}),
},
{
key: "youtube-vpn",
label: "YouTube → VPN",
description: "YouTube/Google Video через VPN.",
build: () =>
template("YouTube", "vpn", {
domainSuffixes: [
"youtube.com",
"youtu.be",
"ytimg.com",
"googlevideo.com",
"youtube-nocookie.com",
],
}),
},
{
key: "steam-direct",
label: "Steam → direct",
description: "Загрузка/обновления Steam напрямую.",
build: () =>
template("Steam", "direct", {
domainSuffixes: [
"steampowered.com",
"steamcontent.com",
"steamcommunity.com",
"steamserver.net",
"cm.steampowered.com",
],
}),
},
{
key: "ads-block",
label: "Реклама → block",
description: "Базовый набор рекламных доменов — заблокировать.",
build: () =>
template("Реклама (block)", "block", {
domainSuffixes: [
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"adservice.google.com",
"adnxs.com",
],
}),
},
];

View File

@@ -28,10 +28,11 @@ export function subscriptionDomain(subscriptionHost) {
}
}
export function localProxyUrls(port = 8082) {
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return {
socks5: `socks5://127.0.0.1:${port}`,
http: `http://127.0.0.1:${port}`,
socks5: `socks5://${urlHost}:${port}`,
http: `http://${urlHost}:${port}`,
};
}

View File

@@ -1,37 +0,0 @@
// Грубое определение страны по тегу сервера и/или хосту.
// Это эвристика — мы не делаем GeoIP-lookup.
const COUNTRIES = [
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
{
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
code: "US",
flag: "🇺🇸",
},
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },
{ re: /\b(sg|singapore)\b/i, code: "SG", flag: "🇸🇬" },
{ re: /\b(hk|hongkong|hong[-_ ]?kong)\b/i, code: "HK", flag: "🇭🇰" },
{ re: /\b(fi|finland|helsinki)\b/i, code: "FI", flag: "🇫🇮" },
{ re: /\b(se|sweden|stockholm)\b/i, code: "SE", flag: "🇸🇪" },
{ re: /\b(pl|poland|warsaw)\b/i, code: "PL", flag: "🇵🇱" },
{ re: /\b(tr|turkey|istanbul)\b/i, code: "TR", flag: "🇹🇷" },
{ re: /\b(ua|ukraine|kiev|kyiv)\b/i, code: "UA", flag: "🇺🇦" },
];
export function detectCountry(...inputs) {
const text = inputs.filter(Boolean).join(" ").toLowerCase();
for (const c of COUNTRIES) {
if (c.re.test(text)) return c;
}
return null;
}
export function flagFor(server) {
if (!server) return "";
const detected = detectCountry(server.tag, server.server);
return detected?.flag || "🌐";
}

View File

@@ -1,56 +0,0 @@
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
const IPV4 =
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
const IPV6 = /^[0-9a-f:]+$/i;
const DOMAIN =
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export function invalidCidrs(values) {
return (values || []).filter((value) => !isValidCidr(value));
}
export function isValidCidr(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
const [addr, mask] = trimmed.split("/");
if (!addr) return false;
if (IPV4.test(addr)) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 32;
}
if (IPV6.test(addr) && addr.includes(":")) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 128;
}
return false;
}
export function invalidPorts(values) {
return (values || []).filter((value) => !isValidPort(value));
}
export function isValidPort(value) {
const n = Number.parseInt(String(value).trim(), 10);
return Number.isInteger(n) && n > 0 && n <= 65535;
}
export function invalidDomains(values) {
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
}
export function ruleErrors(rule) {
return {
domains: invalidDomains(rule.domains),
domainSuffixes: invalidDomains(rule.domainSuffixes),
ipCidrs: invalidCidrs(rule.ipCidrs),
ports: invalidPorts(rule.ports),
};
}
export function hasErrors(errors) {
return Object.values(errors).some((arr) => arr.length > 0);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const entrypoint = fs.readFileSync(
path.resolve(import.meta.dirname, '../../entrypoint.sh'),
'utf8',
);
test('gateway intercepts all public TCP and UDP traffic without source bypasses', () => {
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
});

View File

@@ -1,131 +1,34 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
process.env.APP_MODE = "client";
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-test-"));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
process.env.APP_MODE = 'client';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-client-test-'));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
const { buildGatewayConfig } = await import(
`../../src/server/singbox.js?client-mode=${Date.now()}`
);
const clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?client=${Date.now()}`);
const subscriptionConfig = {
outbounds: [
{
type: "vless",
tag: "test-vpn",
server: "vpn.example.test",
server_port: 443,
uuid: "00000000-0000-4000-8000-000000000000",
tls: { enabled: true },
},
],
customRules: [],
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
test("client mode exposes only the local mixed proxy inbound", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
test('client exposes one local proxy and routes it through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
assert.deepEqual(
config.inbounds.map((inbound) => inbound.tag),
["mixed-in"],
);
assert.equal(config.inbounds[0].type, "mixed");
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
assert.equal(config.inbounds[0].listen_port, 8082);
});
test("client mode routes mixed proxy fallback to the selected VPN", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
assert.equal(config.route.auto_detect_interface, undefined);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-vpn" },
]);
});
test("client home bypass routes the local proxy directly", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
clientSettingsPath,
JSON.stringify({ homeBypassEnabled: true }),
);
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [
{ 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" },
]);
});

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
process.env.APP_MODE = 'gateway';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?gateway=${Date.now()}`);
const subscriptionConfig = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
test('gateway routes transparent and proxy traffic only through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
});

View File

@@ -47,6 +47,13 @@ test('local proxy defaults to the macOS client port', () => {
});
});
test('gateway proxy URLs use its network address', () => {
assert.deepEqual(localProxyUrls(8080, '192.168.50.111'), {
socks5: 'socks5://192.168.50.111:8080',
http: 'http://192.168.50.111:8080',
});
});
test('subscription usage combines traffic and caps progress', () => {
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
upload: 30,