Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s

This commit is contained in:
2026-05-09 09:53:12 +03:00
parent 62b39cdf58
commit aab7533438
14 changed files with 695 additions and 160 deletions

View File

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

View File

@@ -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://<gateway-ip>: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://<gateway-ip>: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://<gateway-ip>: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 трафика |

View File

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

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

153
src/server/devices.js Normal file
View File

@@ -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,
}));
}

View File

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

View File

@@ -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 отправляет напрямую",
};
}

View File

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

View File

@@ -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' && <LogsPage deviceRules={deviceRules} />}
{page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && (
<SettingsPage
state={state}
@@ -413,19 +447,22 @@ function App() {
)}
{/* Sticky bar — для routing/servers */}
{(page === 'routing' && rulesSaveStatus !== 'saved') && (
{(page === 'routing' && dirtyRouting) && (
<div className="sticky-bar">
<div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
<strong>
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
</strong>
<small className="muted">Изменения сохранены, но конфиг не пересобран. Применить на странице «Серверы».</small>
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
</div>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
{rulesSaveStatus !== 'saved' && (
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
)}
{state?.selectedTag && (
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
Применить config

View File

@@ -35,6 +35,15 @@ export const api = {
}),
},
devices: {
get: () => request("/api/devices"),
save: (devicesConfig) =>
request("/api/devices", {
method: "PUT",
body: JSON.stringify(devicesConfig),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>
@@ -93,10 +102,10 @@ export const api = {
},
route: {
check: ({ host, ip, port, network }) =>
check: ({ host, ip, port, network, sourceIp, inbound }) =>
request("/api/route/check", {
method: "POST",
body: JSON.stringify({ host, ip, port, network }),
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
}),
},

View File

@@ -33,14 +33,13 @@ const CATEGORY_BADGE = {
other: { cls: '', label: 'other' },
};
function getDeviceName(sourceIp, deviceRules) {
if (!sourceIp || !deviceRules?.length) return null;
for (const d of deviceRules) {
function getDeviceName(sourceIp, devices) {
if (!sourceIp || !devices?.length) return null;
for (const d of devices) {
if (d.enabled === false) continue;
for (const ip of (d.sourceIps || [])) {
const plain = ip.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name;
}
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name;
}
return null;
}
@@ -64,7 +63,7 @@ function groupTraffic(list, sortBy = 'time') {
return arr.sort((a, b) => b._lastTs - a._lastTs);
}
function TrafficTab({ deviceRules = [] }) {
function TrafficTab({ devices = [] }) {
const [traffic, setTraffic] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
@@ -104,11 +103,11 @@ function TrafficTab({ deviceRules = [] }) {
e.outbound?.toLowerCase().includes(s) ||
e.matchedRule?.toLowerCase().includes(s) ||
e.sourceIp?.toLowerCase().includes(s) ||
getDeviceName(e.sourceIp, deviceRules)?.toLowerCase().includes(s),
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
);
}
return grouped ? groupTraffic(list, sortBy) : list;
}, [traffic, filter, search, grouped, sortBy]);
}, [traffic, filter, search, grouped, sortBy, devices]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
@@ -183,7 +182,7 @@ function TrafficTab({ deviceRules = [] }) {
<tbody>
{filtered.map((e, i) => {
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
const deviceName = getDeviceName(e.sourceIp, deviceRules);
const deviceName = getDeviceName(e.sourceIp, devices);
return (
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
@@ -218,7 +217,7 @@ function TrafficTab({ deviceRules = [] }) {
);
}
export function LogsPage({ deviceRules = [] }) {
export function LogsPage({ devices = [] }) {
const [tab, setTab] = useState('traffic'); // traffic | logs
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
@@ -276,7 +275,7 @@ export function LogsPage({ deviceRules = [] }) {
</div>
</div>
{tab === 'traffic' && <TrafficTab deviceRules={deviceRules} />}
{tab === 'traffic' && <TrafficTab devices={devices} />}
{tab === 'logs' && (
<>

View File

@@ -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 (
<div className="card">
<div className="card-header">
@@ -148,8 +150,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) {
{state?.routingRuDirect && (
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success"> direct</span></div>
)}
<div className="row"><span className="key">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Остальное</span><span className="val text-warning"> VPN</span></div>
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Transparent fallback</span><span className="val"> {transparentDefault}</span></div>
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning"> {proxyDefault}</span></div>
{cacheAvailable && (
<div className="row">
<span className="key">Direct bypass cache</span>

View File

@@ -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() {
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input
className="input"
placeholder="source IP"
value={sourceIp}
onChange={(e) => setSourceIp(e.target.value)}
style={{ width: 145 }}
/>
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
<option value="tproxy-in">tproxy-in</option>
<option value="mixed-in">mixed-in</option>
</select>
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div>

View File

@@ -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 (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="rules">default</option>
<option value="block">block</option>
</select>
);
}
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
const devices = devicesConfig?.devices || [];
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'direct';
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<h2>Маршрутизация по устройствам</h2>
<button className="btn btn-primary sm" onClick={onAdd}>
+ Добавить устройство
</button>
<div>
<h2>Устройства</h2>
<small className="muted">Global rules применяются первыми. Эти значения fallback после них.</small>
</div>
<div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}>
<span className="field-label">Transparent default</span>
<select
className="select sm"
value={defaultTransparentMode}
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="block">block</option>
</select>
</label>
<label className="field" style={{ minWidth: 160, margin: 0 }}>
<span className="field-label">Proxy default</span>
<select
className="select sm"
value={proxyDefaultMode}
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
>
<option value="vpn">VPN</option>
<option value="direct">direct</option>
<option value="block">block</option>
</select>
</label>
<button className="btn btn-primary sm" onClick={onAdd}>
+ Добавить устройство
</button>
</div>
</div>
<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>
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
@@ -42,14 +89,16 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
<tr>
<th style={{ width: 40 }}></th>
<th>Название</th>
<th>IP-адрес(а) устройства</th>
<th style={{ width: 130 }}>Маршрут</th>
<th style={{ width: 170 }}>IP</th>
<th style={{ width: 150 }}>MAC</th>
<th style={{ width: 150 }}>Mode</th>
<th>Поведение</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{devices.map((dev) => {
const ob = OUTBOUND_KIND[dev.outbound] || OUTBOUND_KIND.direct;
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
return (
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
<td>
@@ -72,29 +121,27 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
<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 }}
value={dev.ip || ''}
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
placeholder="192.168.1.50"
style={{ width: '100%', minWidth: 140 }}
/>
</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>
<input
className="input sm"
value={dev.mac || ''}
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
placeholder="опционально"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
</td>
<td>
<span className={`badge ${mode.kind}`}>{mode.label}</span>
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
</td>
<td>
<button
@@ -195,7 +242,7 @@ function TemplatesModal({ open, onClose, onAdd }) {
export function RoutingPage({
rules, saveStatus, busy,
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
deviceRules = [], onAddDevice, onUpdateDevice, onRemoveDevice,
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
}) {
const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false);
@@ -240,7 +287,8 @@ export function RoutingPage({
<RouteChecker />
<DevicesCard
devices={deviceRules}
devicesConfig={devicesConfig}
onDefaultsChange={onUpdateDeviceDefaults}
onAdd={onAddDevice}
onUpdate={onUpdateDevice}
onRemove={onRemoveDevice}