diff --git a/Dockerfile b/Dockerfile index 279ed83..9840840 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN chmod +x /entrypoint.sh \ ENV PORT=3456 \ PROXY_PORT=8080 \ TPROXY_PORT=7895 \ + DIRECT_BYPASS_CACHE=false \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CACHE=/var/lib/sing-box/cache.db diff --git a/README.md b/README.md index e24c71c..69df50f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ▼ iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY │ - ├─ ipset vpn_direct_bypass (dst IP) → RETURN ← bypass-кэш ядра + ├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш ├─ приватные CIDR (RFC1918, ...) → RETURN └─ TCP/UDP → TPROXY :7895 │ @@ -32,6 +32,12 @@ iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY 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-стримы для веб-интерфейса. @@ -43,7 +49,7 @@ iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY | ---------------- | ------------------------------------------------------------- | | Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` | | Перехват трафика | iptables TProxy + iproute2 policy routing | -| Bypass-кэш | ipset `hash:ip` с TTL | +| 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 | @@ -65,7 +71,7 @@ ip route replace local 0.0.0.0/0 dev lo table 100 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-кэш (см. ниже) + -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 @@ -82,10 +88,12 @@ ipset-кэш намеренно **не** очищается — записи и | Приоритет | Условие | Действие | | --------- | ------------------------------------------- | ---------------------------------------- | | 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 | +| 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 | по умолчанию `direct` | +| 7 | Всё остальное (`final`) | `direct` | Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`. @@ -97,7 +105,9 @@ ipset-кэш намеренно **не** очищается — записи и ## Direct Bypass Cache (ipset) -Оптимизация для прямого трафика: IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace. +Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`. + +Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace. **Цепочка событий:** @@ -121,16 +131,54 @@ ipset-кэш намеренно **не** очищается — записи и 4. Запись истекает через TTL (по умолчанию 1 час). ``` +DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset DIRECT_BYPASS_TTL=3600 # TTL в секундах ``` +## Профили устройств + +Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`: + +```json +{ + "defaultTransparentMode": "direct", + "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.json`. +Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов. | Поле | Тип | Описание | | ---------------- | ---------------------------- | ------------------------------------------- | @@ -189,7 +237,7 @@ Node.js читает stderr sing-box и извлекает трафик двум 1. `[router]`-строка → имя правила сохраняется с TTL 500 мс 2. Следующая строка с `-->` подхватывает имя в поле `matchedRule` 3. Тип трафика: `direct` / `vpn` / `block` по outbound -4. Direct + IPv4 → добавление в ipset bypass-кэш +4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true` ### Группировка и сортировка @@ -205,9 +253,11 @@ Node.js читает stderr sing-box и извлекает трафик двум Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/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 +2. global custom rules +3. geoip-ru / geosite-category-ru → direct +4. `tproxy-in` + device default +5. `mixed-in` + proxy default +6. final → direct --- @@ -237,9 +287,12 @@ UI доступен на `http://:3456`. | `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 записей (секунды) | | `PROXY_BIND_IP` | `127.0.0.1` | Bind для HTTP/SOCKS; `0.0.0.0` для LAN | +| `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 | --- @@ -252,6 +305,7 @@ UI доступен на `http://:3456`. | `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 | @@ -259,8 +313,8 @@ UI доступен на `http://:3456`. | `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-пинг до хоста | +| `POST` | `/api/route/check` | Симулировать маршрут | +| `POST` | `/api/servers/ping` | TCP-пинг до хоста | | `GET` | `/api/logs/stream` | SSE системных логов | | `GET` | `/api/traffic/stream` | SSE трафика | diff --git a/entrypoint.sh b/entrypoint.sh index ffed081..445e911 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,11 +5,18 @@ TPROXY_PORT="${TPROXY_PORT:-7895}" TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" +PROXY_PORT="${PROXY_PORT:-8080}" +PROXY_BIND_IP="${PROXY_BIND_IP:-127.0.0.1}" +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}" 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' "$*" @@ -19,6 +26,13 @@ ipt() { iptables -w "$@" } +cleanup_proxy_firewall() { + ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true + ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true + ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true + ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true +} + cleanup_tproxy() { log "cleanup tproxy rules" ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true @@ -30,11 +44,33 @@ cleanup_tproxy() { } 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 + 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 + done + ipt -A "$PROXY_INPUT_CHAIN" -j DROP + ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" + ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" } setup_tproxy() { @@ -49,8 +85,11 @@ setup_tproxy() { 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 - # Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box - ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN + 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 for cidr in $BYPASS_CIDRS; do ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN @@ -63,6 +102,7 @@ setup_tproxy() { setup_direct_bypass_set setup_tproxy +setup_proxy_firewall node /app/src/server/index.js & APP_PID=$! @@ -71,6 +111,7 @@ shutdown() { log "shutdown requested" kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true + cleanup_proxy_firewall cleanup_tproxy } @@ -78,5 +119,6 @@ trap 'shutdown; exit 0' SIGTERM SIGINT wait "$APP_PID" STATUS=$? +cleanup_proxy_firewall cleanup_tproxy exit "$STATUS" diff --git a/src/server/config.js b/src/server/config.js index bebcb96..fe4f6c0 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"), + devicesPath: path.join(dataDir, "devices.json"), deviceRulesPath: path.join(dataDir, "device-rules.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), hwidPath: path.join(dataDir, "hwid"), diff --git a/src/server/devices.js b/src/server/devices.js new file mode 100644 index 0000000..491ffe4 --- /dev/null +++ b/src/server/devices.js @@ -0,0 +1,153 @@ +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"]); +export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]); +export const DEFAULT_DEVICE_MODE = "direct"; +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(); + if (value === "bypass") return "direct"; + 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, + })); +} diff --git a/src/server/index.js b/src/server/index.js index e20663f..93e5854 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -12,6 +12,11 @@ import { readSingboxConfig, removeSingboxConfig, } from "./singbox.js"; +import { + legacyDeviceRulesFromProfiles, + readDeviceProfiles, + writeDeviceProfiles, +} from "./devices.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; @@ -24,10 +29,11 @@ const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid"); // ─── Direct bypass cache (ipset) ──────────────────────────────────────────── const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass"; const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600"; +const DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true"; const IPSET_AVAILABLE = (() => { try { - spawnSync("ipset", ["version"], { timeout: 1000 }); - return true; + const result = spawnSync("ipset", ["version"], { timeout: 1000 }); + return !result.error && result.status === 0; } catch { return false; } @@ -38,7 +44,7 @@ const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/; let directBypassCount = 0; function addToDirectBypass(ip) { - if (!IPSET_AVAILABLE || !IP_RE.test(ip)) return; + if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE || !IP_RE.test(ip)) return; try { spawnSync( "ipset", @@ -60,7 +66,7 @@ function flushDirectBypass() { } function listDirectBypass() { - if (!IPSET_AVAILABLE) return []; + if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE) return []; try { const result = spawnSync( "ipset", @@ -478,7 +484,7 @@ async function startSingbox() { function publicState() { const state = readJson(settings.statePath, {}); const customRules = readJson(settings.customRulesPath, []); - const deviceRules = readJson(settings.deviceRulesPath, []); + const deviceProfiles = readDeviceProfiles(); const { subscriptionUrl, ...rest } = state; return { mode: "gateway", @@ -492,12 +498,16 @@ function publicState() { subscriptionHost: maskSubscriptionUrl(subscriptionUrl), hasSubscription: Boolean(subscriptionUrl), customRules, - deviceRules, + devicesConfig: deviceProfiles, + devices: deviceProfiles.devices, + deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles), appliedHistory: state.appliedHistory || [], rulesUpdatedAt: state.rulesUpdatedAt || null, + devicesUpdatedAt: state.devicesUpdatedAt || null, rulesAppliedAt: state.rulesAppliedAt || null, bypassMode: Boolean(state.bypassMode), directBypassCount, + directBypassEnabled: DIRECT_BYPASS_CACHE, directBypassAvailable: IPSET_AVAILABLE, ...rest, }; @@ -739,17 +749,69 @@ async function handleApi(req, res) { } if (req.method === "GET" && req.url === "/api/device-rules") { + const deviceProfiles = readDeviceProfiles(); return sendJson(res, 200, { success: true, - deviceRules: readJson(settings.deviceRulesPath, []), + deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles), }); } 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 }); + const devices = []; + for (const rule of rules) { + rule.sourceIps.forEach((ip, index) => { + devices.push({ + id: `${rule.id}-${index}`, + name: rule.name, + enabled: rule.enabled, + ip, + mode: rule.outbound, + }); + }); + } + const profiles = writeDeviceProfiles({ + defaultTransparentMode: "direct", + proxyDefaultMode: "vpn", + devices, + }); + const prevState = readJson(settings.statePath, {}); + writeJson(settings.statePath, { + ...prevState, + devicesUpdatedAt: new Date().toISOString(), + }); + return sendJson(res, 200, { + success: true, + ...profiles, + deviceRules: legacyDeviceRulesFromProfiles(profiles), + }); + } + + if (req.method === "GET" && req.url === "/api/devices") { + const profiles = readDeviceProfiles(); + return sendJson(res, 200, { success: true, ...profiles }); + } + + if (req.method === "PUT" && req.url === "/api/devices") { + const body = await readBody(req); + const input = body.devicesConfig || { + defaultTransparentMode: body.defaultTransparentMode || body.defaultMode, + proxyDefaultMode: body.proxyDefaultMode, + devices: body.devices, + }; + const profiles = writeDeviceProfiles(input); + const prevState = readJson(settings.statePath, {}); + const devicesUpdatedAt = new Date().toISOString(); + writeJson(settings.statePath, { + ...prevState, + devicesUpdatedAt, + }); + return sendJson(res, 200, { + success: true, + ...profiles, + devicesUpdatedAt, + }); } if (req.method === "GET" && req.url === "/api/rule-sets") { @@ -954,6 +1016,8 @@ async function handleApi(req, res) { ? Number(body.port) : undefined; const network = String(body.network || "").trim() || undefined; + const sourceIp = String(body.sourceIp || "").trim() || undefined; + const inbound = String(body.inbound || "").trim() || undefined; if (!host && !ip) { return sendJson(res, 400, { @@ -972,12 +1036,12 @@ async function handleApi(req, res) { } const rules = readJson(settings.customRulesPath, []); - const cached = readJson(settings.subscriptionCachePath, null); const state = readJson(settings.statePath, {}); const vpnTag = state.selectedTag || "vpn-out"; - const result = matchRoute({ host, ip, port, network }, rules, { + const result = matchRoute({ host, ip, port, network, sourceIp, inbound }, rules, { routingRuDirect: settings.routingRuDirect, vpnTag, + deviceProfiles: readDeviceProfiles(), }); return sendJson(res, 200, { diff --git a/src/server/routeMatcher.js b/src/server/routeMatcher.js index a556211..4c509cc 100644 --- a/src/server/routeMatcher.js +++ b/src/server/routeMatcher.js @@ -4,6 +4,7 @@ // мы не можем точно сказать, попадает ли 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)); @@ -49,6 +50,28 @@ function isPrivateIp(ip) { 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(); @@ -116,8 +139,25 @@ function ruleMatches(rule, target) { * @param {object} options { routingRuDirect, vpnTag } */ export function matchRoute(target, customRules, options = {}) { - const { routingRuDirect = true, vpnTag = "vpn-out" } = options; + const { + routingRuDirect = true, + vpnTag = "vpn-out", + deviceProfiles = { + defaultTransparentMode: "direct", + 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), + ); // 1. private IP → direct if (target.ip && isPrivateIp(target.ip)) { @@ -130,7 +170,7 @@ export function matchRoute(target, customRules, options = {}) { }; } - // 2. custom rules (first match wins) + // 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)) { @@ -142,30 +182,68 @@ export function matchRoute(target, customRules, options = {}) { ruleId: rule.id, ruleName: rule.name, outbound, - reason: "Совпадение по custom-правилу", + reason: "Совпадение по global custom rule", }; } } - // 3. RU direct (geoip/geosite) — мы не знаем точно, скажем "может сработать" - if (routingRuDirect) { + // 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts. + if (routingRuDirect && likelyRuHost(target.host)) { return { - matched: "fallback-ru-or-vpn", + matched: "geo", ruleIndex: -2, - ruleName: "geoip-ru / geosite-category-ru → direct, иначе VPN", - outbound: `direct или ${vpnTag}`, - reason: - "Если домен/IP попадает в geoip-ru или geosite-category-ru — direct; иначе — VPN. Без локальной базы точно не определить.", + ruleName: "geosite-category-ru → direct", + outbound: "direct", + reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box", }; } - // 4. final → VPN + // 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 || "direct"; + 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: vpnTag, - reason: "Не сработало ни одно правило — пойдёт через VPN", + outbound: "direct", + reason: "Не сработало ни одно правило — итоговый final отправляет напрямую", }; } diff --git a/src/server/singbox.js b/src/server/singbox.js index aa16516..8c58a15 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -1,6 +1,12 @@ 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"; const PROXY_TYPES = new Set([ "vless", @@ -100,11 +106,11 @@ function parsePorts(values) { .filter((value) => Number.isInteger(value) && value > 0 && value <= 65535); } -function toSingboxRule(customRule, vpnTag) { +function toSingboxRule(customRule, vpnTag, baseRule = {}) { if (!customRule?.enabled) return null; if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null; - const rule = {}; + const rule = { ...baseRule }; const domains = uniqueClean(customRule.domains); const domainSuffixes = uniqueClean(customRule.domainSuffixes); const domainKeywords = uniqueClean(customRule.domainKeywords); @@ -140,47 +146,57 @@ function toSingboxRule(customRule, vpnTag) { return rule; } -function customRouteRules(customRules, vpnTag) { +function customRouteRules(customRules, vpnTag, baseRule = {}) { return (Array.isArray(customRules) ? customRules : []) - .map((rule) => toSingboxRule(rule, vpnTag)) + .map((rule) => toSingboxRule(rule, vpnTag, baseRule)) .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 modeOutbound(mode, vpnTag) { + if (mode === "vpn") return vpnTag; + if (mode === "direct" || mode === "block") return mode; + return null; } -function normalizeCidr(ip) { - return ip.includes("/") ? ip : `${ip}/32`; -} - -function toDeviceRouteRule(device, vpnTag) { +function deviceDefaultRouteRule(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 }; + 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 deviceRouteRules(deviceRules, vpnTag) { - return (Array.isArray(deviceRules) ? deviceRules : []) - .map((d) => toDeviceRouteRule(d, vpnTag)) +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) { - const deviceRules = readDeviceRules(); + const deviceProfiles = readDeviceProfiles(); const rules = [ { ip_is_private: true, @@ -188,17 +204,28 @@ function routeRules(customRules, vpnTag) { }, ]; - // Правила по устройствам (source IP) — выполняются ДО правил по назначению - rules.push(...deviceRouteRules(deviceRules, vpnTag)); - + // Global rules apply to every inbound before contextual fallbacks. rules.push(...customRouteRules(customRules, vpnTag)); - if (settings.routingRuDirect) { - rules.push({ - rule_set: ["geoip-ru", "geosite-category-ru"], - outbound: "direct", - }); - } + const ruRule = ruDirectRule(); + if (ruRule) rules.push(ruRule); + + // 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); + + const transparentFallback = inboundDefaultRule( + TPROXY_INBOUND, + deviceProfiles.defaultTransparentMode, + vpnTag, + ); + if (transparentFallback) rules.push(transparentFallback); return rules; } @@ -263,7 +290,7 @@ export function buildGatewayConfig( rules: bypassAll ? [{ ip_is_private: true, outbound: "direct" }] : routeRules(subscriptionConfig.customRules, vpnOutbound.tag), - final: bypassAll ? "direct" : vpnOutbound.tag, + final: "direct", auto_detect_interface: true, }, }; diff --git a/src/web/App.jsx b/src/web/App.jsx index aa4de3f..132c0ff 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -27,7 +27,11 @@ function App() { const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [servers, setServers] = useState([]); const [customRules, setCustomRules] = useState([]); - const [deviceRules, setDeviceRules] = useState([]); + const [devicesConfig, setDevicesConfig] = useState({ + defaultTransparentMode: 'direct', + proxyDefaultMode: 'vpn', + devices: [], + }); const [selectedTag, setSelectedTag] = useState(''); const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); @@ -68,7 +72,11 @@ function App() { setState(data); setServers(data.servers || []); if (!rulesDirtyRef.current) setCustomRules(data.customRules || []); - setDeviceRules(data.deviceRules || []); + setDevicesConfig(data.devicesConfig || { + defaultTransparentMode: 'direct', + proxyDefaultMode: 'vpn', + devices: data.devices || [], + }); setSelectedTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || ''); } @@ -195,35 +203,55 @@ function App() { }); } - // === Device Rules === - async function saveDeviceRules(rules) { + // === Devices === + async function saveDevicesConfig(nextConfig) { try { - const data = await api.deviceRules.save(rules); - setDeviceRules(data.deviceRules || rules); + const data = await api.devices.save(nextConfig); + setDevicesConfig({ + defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'direct', + proxyDefaultMode: data.proxyDefaultMode || 'vpn', + devices: data.devices || [], + }); + setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev); } 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); + 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 next = deviceRules.map((d) => (d.id === id ? { ...d, ...patch } : d)); - setDeviceRules(next); - saveDeviceRules(next); + const nextConfig = { + ...devicesConfig, + devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)), + }; + setDevicesConfig(nextConfig); + saveDevicesConfig(nextConfig); } function removeDevice(id) { - const next = deviceRules.filter((d) => d.id !== id); - setDeviceRules(next); - saveDeviceRules(next); + 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 === @@ -326,11 +354,16 @@ function App() { ); const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving'; + const dirtyDevices = Boolean( + state?.devicesUpdatedAt && + (!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt), + ); const dirtyServer = pendingTag && pendingTag !== state?.selectedTag; - const dirty = dirtyRules || dirtyServer; + const dirtyRouting = dirtyRules || dirtyDevices; + const dirty = dirtyRouting || dirtyServer; const sidebarBadges = { - routing: dirtyRules ? { kind: 'warn', text: '●' } : null, + routing: dirtyRouting ? { kind: 'warn', text: '●' } : null, servers: dirtyServer ? { kind: 'warn', text: '●' } : null, settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null, }; @@ -391,13 +424,14 @@ function App() { onRemove={removeRule} onSaveNow={saveRulesNow} onReorder={reorderRules} - deviceRules={deviceRules} + devicesConfig={devicesConfig} + onUpdateDeviceDefaults={updateDeviceDefaults} onAddDevice={addDevice} onUpdateDevice={updateDevice} onRemoveDevice={removeDevice} /> )} - {page === 'logs' && } + {page === 'logs' && } {page === 'settings' && (
{rulesSaveStatus === 'saving' && 'Сохраняем…'} {rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'} + {rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'} {rulesSaveStatus === 'error' && 'Ошибка сохранения'} - Изменения сохранены, но конфиг не пересобран. Применить — на странице «Серверы». + Конфиг sing-box нужно пересобрать и применить.
- + {rulesSaveStatus !== 'saved' && ( + + )} {state?.selectedTag && (
- {tab === 'traffic' && } + {tab === 'traffic' && } {tab === 'logs' && ( <> diff --git a/src/web/components/OverviewPage.jsx b/src/web/components/OverviewPage.jsx index ff9aa3d..18d59a5 100644 --- a/src/web/components/OverviewPage.jsx +++ b/src/web/components/OverviewPage.jsx @@ -136,7 +136,9 @@ 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; + const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled; + const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'direct'; + const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn'; return (
@@ -148,8 +150,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) { {state?.routingRuDirect && (
RU (geoip/geosite)→ direct
)} -
Custom правил{enabled} из {rules.length}
-
Остальное→ VPN
+
Global custom правил{enabled} из {rules.length}
+
Transparent fallback→ {transparentDefault}
+
Proxy fallback→ {proxyDefault}
{cacheAvailable && (
Direct bypass cache diff --git a/src/web/components/RouteChecker.jsx b/src/web/components/RouteChecker.jsx index 0683206..06b5bed 100644 --- a/src/web/components/RouteChecker.jsx +++ b/src/web/components/RouteChecker.jsx @@ -5,6 +5,8 @@ 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(''); @@ -14,7 +16,13 @@ export function RouteChecker() { setError(''); setResult(null); try { - const data = await api.route.check({ host, port: port || undefined, network }); + const data = await api.route.check({ + host, + port: port || undefined, + network, + sourceIp: sourceIp || undefined, + inbound, + }); setResult(data); } catch (err) { setError(err.message); @@ -52,6 +60,17 @@ export function RouteChecker() { + setSourceIp(e.target.value)} + style={{ width: 145 }} + /> +
diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx index 011d654..6cb4c7c 100644 --- a/src/web/components/RoutingPage.jsx +++ b/src/web/components/RoutingPage.jsx @@ -18,22 +18,69 @@ const OUTBOUND_KIND = { block: { kind: 'danger', label: 'block' }, }; -function DevicesCard({ devices, onAdd, onUpdate, onRemove }) { +const DEVICE_MODES = { + 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 ( + + ); +} + +function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) { + const devices = devicesConfig?.devices || []; + const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'direct'; + const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn'; + return (
-

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

- +
+

Устройства

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

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

+

Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.

) : (
@@ -42,14 +89,16 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) { Название - IP-адрес(а) устройства - Маршрут + IP + MAC + Mode + Поведение {devices.map((dev) => { - const ob = OUTBOUND_KIND[dev.outbound] || OUTBOUND_KIND.direct; + const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules; return ( @@ -72,29 +121,27 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) { - onUpdate(dev.id, { - sourceIps: e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - }) - } - placeholder="192.168.1.100" - style={{ width: '100%', minWidth: 160 }} + value={dev.ip || ''} + onChange={(e) => onUpdate(dev.id, { ip: e.target.value })} + placeholder="192.168.1.50" + style={{ width: '100%', minWidth: 140 }} /> - + onUpdate(dev.id, { mac: e.target.value })} + placeholder="опционально" + style={{ width: '100%', minWidth: 120 }} + /> + + + onUpdate(dev.id, { mode })} /> + + + {mode.label} + {mode.hint}