feat: добавлены правила маршрутизации по устройствам и управление ими через API
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s

Refs: None
This commit is contained in:
2026-05-09 09:12:03 +03:00
parent b3fad00f80
commit 4bb8507e3f
7 changed files with 506 additions and 42 deletions

328
README.md
View File

@@ -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 и порты.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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