diff --git a/README.md b/README.md index 3a2f3f7..e24c71c 100644 --- a/README.md +++ b/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://:3456`. +UI доступен на `http://: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 и порты. diff --git a/src/server/config.js b/src/server/config.js index 8f1c6e2..bebcb96 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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", diff --git a/src/server/index.js b/src/server/index.js index 10b5045..b87d294 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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, diff --git a/src/server/singbox.js b/src/server/singbox.js index c27c957..aa16516 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -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) { diff --git a/src/web/App.jsx b/src/web/App.jsx index b0f232b..190b677 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -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' && } diff --git a/src/web/api.js b/src/web/api.js index aa691e0..827027e 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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) => diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx index 76769a6..011d654 100644 --- a/src/web/components/RoutingPage.jsx +++ b/src/web/components/RoutingPage.jsx @@ -18,6 +18,103 @@ const OUTBOUND_KIND = { block: { kind: 'danger', label: 'block' }, }; +function DevicesCard({ devices, onAdd, onUpdate, onRemove }) { + return ( +
+
+

Маршрутизация по устройствам

+ +
+ + Правила по source IP — выполняются до правил маршрутизации. + Укажи IP устройства в сети и куда направлять весь его трафик. + + {devices.length === 0 ? ( +
+

Нет правил по устройствам — все используют общую маршрутизацию.

+
+ ) : ( +
+ + + + + + + + + + + + {devices.map((dev) => { + const ob = OUTBOUND_KIND[dev.outbound] || OUTBOUND_KIND.direct; + return ( + + + + + + + + ); + })} + +
НазваниеIP-адрес(а) устройстваМаршрут
+ onUpdate(dev.id, { enabled: e.target.checked })} + style={{ accentColor: 'var(--accent)' }} + /> + + onUpdate(dev.id, { name: e.target.value })} + placeholder="Название устройства" + style={{ width: '100%', minWidth: 120 }} + /> + + + onUpdate(dev.id, { + sourceIps: e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }) + } + placeholder="192.168.1.100" + style={{ width: '100%', minWidth: 160 }} + /> + + + + +
+
+ )} +
+ ); +} + 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({
+ +

Правила маршрутизации