feat: добавлены правила маршрутизации по устройствам и управление ими через API
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
This commit is contained in:
328
README.md
328
README.md
@@ -1,57 +1,301 @@
|
||||
# VPN Proxy Gateway
|
||||
|
||||
Контейнер запускается в `network_mode: host`, применяет TProxy-правила на хосте и
|
||||
запускает `sing-box` как прозрачный gateway для устройств в локальной сети.
|
||||
Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
|
||||
|
||||
## Возможности
|
||||
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
|
||||
|
||||
- Web UI на Vite + React, всё на русском.
|
||||
- Один Node control-server без отдельного backend framework.
|
||||
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links.
|
||||
- Подписка маскируется в UI после загрузки, кнопка «Забыть подписку» — стирает
|
||||
кэш, останавливает sing-box и удаляет конфиг.
|
||||
- Управление жизненным циклом sing-box из UI: остановить, перезапустить, сбросить
|
||||
конфиг, посмотреть сгенерированный `config.json` (read-only).
|
||||
- Live-логи sing-box через SSE (фильтр по уровню, пауза, очистка).
|
||||
- Routing lists с автосохранением, drag-n-drop порядка (first match wins),
|
||||
валидацией CIDR/портов/доменов и шаблонами (LoL, Discord, Telegram, YouTube,
|
||||
Steam, реклама).
|
||||
- Генерация sing-box config с safety private-direct, кастомными правилами и
|
||||
RU geosite/geoip direct.
|
||||
- Docker entrypoint с idempotent TProxy setup/cleanup.
|
||||
- Healthcheck в compose: `curl http://127.0.0.1:${PORT}/api/state`.
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Клиент (ПК/телефон)
|
||||
│ TCP/UDP трафик
|
||||
▼
|
||||
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
|
||||
│
|
||||
▼
|
||||
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||
│
|
||||
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← bypass-кэш ядра
|
||||
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||
└─ TCP/UDP → TPROXY :7895
|
||||
│
|
||||
▼
|
||||
sing-box (tproxy inbound :7895)
|
||||
│
|
||||
роутинг по правилам
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
▼ ▼ ▼
|
||||
direct VPN out block
|
||||
```
|
||||
|
||||
**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
|
||||
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
||||
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
||||
-m set --match-set vpn_direct_bypass → RETURN # bypass-кэш (см. ниже)
|
||||
-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.
|
||||
|
||||
### 2. Маршрутизация внутри sing-box
|
||||
|
||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||
|
||||
| Приоритет | Условие | Действие |
|
||||
| --------- | ------------------------------------------- | ---------------------------------------- |
|
||||
| 1 | `ip_is_private: true` | `direct` (защита LAN) |
|
||||
| 2 | Правила по устройствам (source IP) | `direct` / `vpn` / `block` |
|
||||
| 3 | Кастомные правила пользователя | `direct` / `vpn` / `block` |
|
||||
| 4 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` (если `ROUTING_RU_DIRECT=true`) |
|
||||
| 5 | Всё остальное (`final`) | выбранный VPN-outbound |
|
||||
|
||||
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
|
||||
|
||||
### 3. Bypass Mode (весь трафик напрямую)
|
||||
|
||||
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
|
||||
|
||||
---
|
||||
|
||||
## Direct Bypass Cache (ipset)
|
||||
|
||||
Оптимизация для прямого трафика: 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_SET=vpn_direct_bypass # имя ipset
|
||||
DIRECT_BYPASS_TTL=3600 # TTL в секундах
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Кастомные правила маршрутизации
|
||||
|
||||
Управляются из вкладки **Правила**. Сохраняются в `custom-rules.json`.
|
||||
Правила применяются в порядке отображения в UI — **first match wins**.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------- |
|
||||
| `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-кэш
|
||||
|
||||
### Группировка и сортировка
|
||||
|
||||
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
|
||||
|
||||
- **По частоте** — самые частые наверху (по умолчанию)
|
||||
- **По времени** — последние наверху
|
||||
|
||||
---
|
||||
|
||||
## Проверка маршрута
|
||||
|
||||
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
|
||||
|
||||
1. private IP → direct
|
||||
2. custom rules (first-match)
|
||||
3. geoip-ru / geosite-category-ru → "вероятно direct" (без локальной БД точно неизвестно)
|
||||
4. final → VPN
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.gateway.yml up -d --build
|
||||
# Сборка фронтенда
|
||||
npm install && npm run build
|
||||
|
||||
# Запуск контейнера
|
||||
docker compose -f docker-compose.gateway.yml up -d
|
||||
```
|
||||
|
||||
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
||||
UI доступен на `http://<gateway-ip>:3456`.
|
||||
|
||||
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
| ------------------- | -------------------- | -------------------------------------- |
|
||||
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
|
||||
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
|
||||
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
|
||||
| `PROXY_BIND_IP` | `127.0.0.1` | Bind для HTTP/SOCKS; `0.0.0.0` для LAN |
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
| --------- | ----------------------------------- | ------------------------------------------------------------------ |
|
||||
| GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription |
|
||||
| GET | `/api/config` | текущий sing-box config |
|
||||
| GET | `/api/logs` | последние 200 строк логов |
|
||||
| GET | `/api/logs/stream` | SSE-поток логов sing-box |
|
||||
| GET / PUT | `/api/rules` | список кастомных правил |
|
||||
| POST | `/api/subscription/fetch` | загрузить подписку |
|
||||
| DELETE | `/api/subscription` | удалить подписку, остановить sing-box |
|
||||
| POST | `/api/apply` | применить выбранный сервер |
|
||||
| POST | `/api/singbox/{stop,restart,clear}` | управление процессом |
|
||||
| Метод | Путь | Описание |
|
||||
| --------- | ---------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/state` | Полное состояние системы |
|
||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||
| `GET/PUT` | `/api/rules` | Кастомные правила |
|
||||
| `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` | Симулировать маршрут |
|
||||
| `GET` | `/api/ping` | TCP-пинг до хоста |
|
||||
| `GET` | `/api/logs/stream` | SSE системных логов |
|
||||
| `GET` | `/api/traffic/stream` | SSE трафика |
|
||||
|
||||
## Важные ограничения
|
||||
---
|
||||
|
||||
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
||||
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать
|
||||
клиентам DNS через роутер/DHCP.
|
||||
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
||||
- Mixed proxy по умолчанию слушает `127.0.0.1` (для дома). Чтобы открыть для LAN,
|
||||
установи `PROXY_BIND_IP=0.0.0.0` в `.env`.
|
||||
- Gateway не видит process name на клиентском ПК, поэтому правила для игр
|
||||
задаются через домены, suffix, IP CIDR и порты.
|
||||
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── 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 и порты.
|
||||
|
||||
@@ -15,6 +15,7 @@ export const settings = {
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
|
||||
@@ -436,6 +436,7 @@ async function startSingbox() {
|
||||
function publicState() {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const deviceRules = readJson(settings.deviceRulesPath, []);
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
return {
|
||||
mode: "gateway",
|
||||
@@ -449,6 +450,7 @@ function publicState() {
|
||||
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
|
||||
hasSubscription: Boolean(subscriptionUrl),
|
||||
customRules,
|
||||
deviceRules,
|
||||
appliedHistory: state.appliedHistory || [],
|
||||
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
||||
rulesAppliedAt: state.rulesAppliedAt || null,
|
||||
@@ -492,6 +494,21 @@ function normalizeCustomRules(input) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeDeviceRules(input) {
|
||||
const rules = Array.isArray(input) ? input : [];
|
||||
return rules.map((r, index) => ({
|
||||
id: String(r.id || `dev-${Date.now()}-${index}`),
|
||||
name: String(r.name || `Устройство ${index + 1}`).trim(),
|
||||
enabled: r.enabled !== false,
|
||||
sourceIps: normalizeList(r.sourceIps).filter((ip) =>
|
||||
/^[\.\d:/]+$/.test(ip),
|
||||
),
|
||||
outbound: ["direct", "vpn", "block"].includes(r.outbound)
|
||||
? r.outbound
|
||||
: "direct",
|
||||
}));
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) {
|
||||
@@ -679,6 +696,20 @@ async function handleApi(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/device-rules") {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
deviceRules: readJson(settings.deviceRulesPath, []),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && req.url === "/api/device-rules") {
|
||||
const body = await readBody(req);
|
||||
const rules = normalizeDeviceRules(body.deviceRules);
|
||||
writeJson(settings.deviceRulesPath, rules);
|
||||
return sendJson(res, 200, { success: true, deviceRules: rules });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/rule-sets") {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
|
||||
@@ -146,7 +146,41 @@ function customRouteRules(customRules, vpnTag) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
|
||||
|
||||
function readDeviceRules() {
|
||||
try {
|
||||
if (!fs.existsSync(settings.deviceRulesPath)) return [];
|
||||
const data = JSON.parse(fs.readFileSync(settings.deviceRulesPath, "utf8"));
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCidr(ip) {
|
||||
return ip.includes("/") ? ip : `${ip}/32`;
|
||||
}
|
||||
|
||||
function toDeviceRouteRule(device, vpnTag) {
|
||||
if (!device?.enabled) return null;
|
||||
const cidrs = (Array.isArray(device.sourceIps) ? device.sourceIps : [])
|
||||
.map((ip) => normalizeCidr(ip.trim()))
|
||||
.filter(Boolean);
|
||||
if (!cidrs.length) return null;
|
||||
const outbound =
|
||||
device.outbound === "vpn" ? vpnTag : device.outbound || "direct";
|
||||
return { source_ip_cidr: cidrs, outbound };
|
||||
}
|
||||
|
||||
function deviceRouteRules(deviceRules, vpnTag) {
|
||||
return (Array.isArray(deviceRules) ? deviceRules : [])
|
||||
.map((d) => toDeviceRouteRule(d, vpnTag))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function routeRules(customRules, vpnTag) {
|
||||
const deviceRules = readDeviceRules();
|
||||
const rules = [
|
||||
{
|
||||
ip_is_private: true,
|
||||
@@ -154,6 +188,9 @@ function routeRules(customRules, vpnTag) {
|
||||
},
|
||||
];
|
||||
|
||||
// Правила по устройствам (source IP) — выполняются ДО правил по назначению
|
||||
rules.push(...deviceRouteRules(deviceRules, vpnTag));
|
||||
|
||||
rules.push(...customRouteRules(customRules, vpnTag));
|
||||
|
||||
if (settings.routingRuDirect) {
|
||||
|
||||
@@ -27,6 +27,7 @@ function App() {
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
const [servers, setServers] = useState([]);
|
||||
const [customRules, setCustomRules] = useState([]);
|
||||
const [deviceRules, setDeviceRules] = useState([]);
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -67,6 +68,7 @@ function App() {
|
||||
setState(data);
|
||||
setServers(data.servers || []);
|
||||
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||
setDeviceRules(data.deviceRules || []);
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
@@ -193,6 +195,37 @@ function App() {
|
||||
});
|
||||
}
|
||||
|
||||
// === Device Rules ===
|
||||
async function saveDeviceRules(rules) {
|
||||
try {
|
||||
const data = await api.deviceRules.save(rules);
|
||||
setDeviceRules(data.deviceRules || rules);
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const next = [
|
||||
...deviceRules,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, sourceIps: [], outbound: 'direct' },
|
||||
];
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
}
|
||||
|
||||
function updateDevice(id, patch) {
|
||||
const next = deviceRules.map((d) => (d.id === id ? { ...d, ...patch } : d));
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
const next = deviceRules.filter((d) => d.id !== id);
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
}
|
||||
|
||||
// === Rules CRUD ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
@@ -358,6 +391,10 @@ function App() {
|
||||
onRemove={removeRule}
|
||||
onSaveNow={saveRulesNow}
|
||||
onReorder={reorderRules}
|
||||
deviceRules={deviceRules}
|
||||
onAddDevice={addDevice}
|
||||
onUpdateDevice={updateDevice}
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && <LogsPage />}
|
||||
|
||||
@@ -26,6 +26,15 @@ export const api = {
|
||||
conflicts: () => request("/api/rules/conflicts"),
|
||||
},
|
||||
|
||||
deviceRules: {
|
||||
get: () => request("/api/device-rules"),
|
||||
save: (deviceRules) =>
|
||||
request("/api/device-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ deviceRules }),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
get: () => request("/api/rule-sets"),
|
||||
save: (ruleSets) =>
|
||||
|
||||
@@ -18,6 +18,103 @@ const OUTBOUND_KIND = {
|
||||
block: { kind: 'danger', label: 'block' },
|
||||
};
|
||||
|
||||
function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Маршрутизация по устройствам</h2>
|
||||
<button className="btn btn-primary sm" onClick={onAdd}>
|
||||
+ Добавить устройство
|
||||
</button>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Правила по source IP — выполняются <strong>до</strong> правил маршрутизации.
|
||||
Укажи IP устройства в сети и куда направлять весь его трафик.
|
||||
</small>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>
|
||||
<p style={{ margin: 0 }}>Нет правил по устройствам — все используют общую маршрутизацию.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Название</th>
|
||||
<th>IP-адрес(а) устройства</th>
|
||||
<th style={{ width: 130 }}>Маршрут</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((dev) => {
|
||||
const ob = OUTBOUND_KIND[dev.outbound] || OUTBOUND_KIND.direct;
|
||||
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.sourceIps || []).join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate(dev.id, {
|
||||
sourceIps: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="192.168.1.100"
|
||||
style={{ width: '100%', minWidth: 160 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="select sm"
|
||||
value={dev.outbound || 'direct'}
|
||||
onChange={(e) => onUpdate(dev.id, { outbound: e.target.value })}
|
||||
>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</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);
|
||||
@@ -98,6 +195,7 @@ function TemplatesModal({ open, onClose, onAdd }) {
|
||||
export function RoutingPage({
|
||||
rules, saveStatus, busy,
|
||||
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
|
||||
deviceRules = [], onAddDevice, onUpdateDevice, onRemoveDevice,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
@@ -141,6 +239,13 @@ export function RoutingPage({
|
||||
<div className="section-stack">
|
||||
<RouteChecker />
|
||||
|
||||
<DevicesCard
|
||||
devices={deviceRules}
|
||||
onAdd={onAddDevice}
|
||||
onUpdate={onUpdateDevice}
|
||||
onRemove={onRemoveDevice}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Правила маршрутизации</h2>
|
||||
|
||||
Reference in New Issue
Block a user