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 \ ENV PORT=3456 \
PROXY_PORT=8080 \ PROXY_PORT=8080 \
TPROXY_PORT=7895 \ TPROXY_PORT=7895 \
DIRECT_BYPASS_CACHE=false \
DATA_DIR=/var/lib/vpn-proxy \ DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db SING_BOX_CACHE=/var/lib/sing-box/cache.db

View File

@@ -18,7 +18,7 @@
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY 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 ├─ приватные CIDR (RFC1918, ...) → RETURN
└─ TCP/UDP → TPROXY :7895 └─ TCP/UDP → TPROXY :7895
@@ -32,6 +32,12 @@ iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
direct VPN out block 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`) работает внутри того же контейнера: **Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса. управляет процессом 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` | | Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
| Перехват трафика | iptables TProxy + iproute2 policy routing | | Перехват трафика | 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) | | VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
| API-сервер | Node.js 18, plain `http` (без фреймворков) | | API-сервер | Node.js 18, plain `http` (без фреймворков) |
| Веб-интерфейс | React 18 + Vite 7, SPA | | Веб-интерфейс | 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 iptables -t mangle -N VPN_PROXY_TPROXY
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box -m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты -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 # приватные адреса -d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1 -p tcp → TPROXY :7895 mark 1
-p udp → TPROXY :7895 mark 1 -p udp → TPROXY :7895 mark 1
@@ -82,10 +88,12 @@ ipset-кэш намеренно **не** очищается — записи и
| Приоритет | Условие | Действие | | Приоритет | Условие | Действие |
| --------- | ------------------------------------------- | ---------------------------------------- | | --------- | ------------------------------------------- | ---------------------------------------- |
| 1 | `ip_is_private: true` | `direct` (защита LAN) | | 1 | `ip_is_private: true` | `direct` (защита LAN) |
| 2 | Правила по устройствам (source IP) | `direct` / `vpn` / `block` | | 2 | Global custom rules | `direct` / VPN / `block` для всех inbound |
| 3 | Кастомные правила пользователя | `direct` / `vpn` / `block` | | 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` |
| 4 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` (если `ROUTING_RU_DIRECT=true`) | | 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` |
| 5 | Всё остальное (`final`) | выбранный VPN-outbound | | 5 | Proxy default для `mixed-in` | по умолчанию VPN |
| 6 | Transparent default для unknown devices | по умолчанию `direct` |
| 7 | Всё остальное (`final`) | `direct` |
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`. Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
@@ -97,7 +105,9 @@ ipset-кэш намеренно **не** очищается — записи и
## Direct Bypass Cache (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 час). 4. Запись истекает через TTL (по умолчанию 1 час).
``` ```
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
DIRECT_BYPASS_TTL=3600 # TTL в секундах 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`. Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
Правила применяются в порядке отображения в UI — **first match wins**. Правила применяются в порядке отображения в 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 мс 1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule` 2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
3. Тип трафика: `direct` / `vpn` / `block` по outbound 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: Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
1. private IP → direct 1. private IP → direct
2. custom rules (first-match) 2. global custom rules
3. geoip-ru / geosite-category-ru → "вероятно direct" (без локальной БД точно неизвестно) 3. geoip-ru / geosite-category-ru → direct
4. final → VPN 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) | | `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct | | `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
| `LOG_LEVEL` | `info` | Уровень логов sing-box | | `LOG_LEVEL` | `info` | Уровень логов sing-box |
| `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен |
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша | | `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) | | `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
| `PROXY_BIND_IP` | `127.0.0.1` | Bind для HTTP/SOCKS; `0.0.0.0` для LAN | | `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 }`) | | `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
| `GET` | `/api/servers` | Список серверов из кэша | | `GET` | `/api/servers` | Список серверов из кэша |
| `GET/PUT` | `/api/rules` | Кастомные правила | | `GET/PUT` | `/api/rules` | Кастомные правила |
| `GET/PUT` | `/api/devices` | Профили устройств и default fallback |
| `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set | | `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set |
| `POST` | `/api/singbox/start` | Запустить sing-box | | `POST` | `/api/singbox/start` | Запустить sing-box |
| `POST` | `/api/singbox/stop` | Остановить sing-box | | `POST` | `/api/singbox/stop` | Остановить sing-box |
@@ -259,8 +313,8 @@ UI доступен на `http://<gateway-ip>:3456`.
| `POST` | `/api/bypass` | `{ enabled }` — bypass mode | | `POST` | `/api/bypass` | `{ enabled }` — bypass mode |
| `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша | | `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша |
| `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш | | `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш |
| `POST` | `/api/route-check` | Симулировать маршрут | | `POST` | `/api/route/check` | Симулировать маршрут |
| `GET` | `/api/ping` | TCP-пинг до хоста | | `POST` | `/api/servers/ping` | TCP-пинг до хоста |
| `GET` | `/api/logs/stream` | SSE системных логов | | `GET` | `/api/logs/stream` | SSE системных логов |
| `GET` | `/api/traffic/stream` | SSE трафика | | `GET` | `/api/traffic/stream` | SSE трафика |

View File

@@ -5,11 +5,18 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" 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}" 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) # Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}" DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
# TTL записи в ipset (секунды). По умолчанию 1 час. # TTL записи в ipset (секунды). По умолчанию 1 час.
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}" DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
# Direct bypass cache выключен по умолчанию, потому что он обходит global rules.
DIRECT_BYPASS_CACHE="${DIRECT_BYPASS_CACHE:-false}"
log() { log() {
printf '[gateway-entrypoint] %s\n' "$*" printf '[gateway-entrypoint] %s\n' "$*"
@@ -19,6 +26,13 @@ ipt() {
iptables -w "$@" 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() { cleanup_tproxy() {
log "cleanup tproxy rules" log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
@@ -30,11 +44,33 @@ cleanup_tproxy() {
} }
setup_direct_bypass_set() { 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)" log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи) # Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
# Экспортируем имя для использования в Node.js через env # Экспортируем имя для использования в 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() { 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 addrtype --dst-type LOCAL -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN # 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 for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
@@ -63,6 +102,7 @@ setup_tproxy() {
setup_direct_bypass_set setup_direct_bypass_set
setup_tproxy setup_tproxy
setup_proxy_firewall
node /app/src/server/index.js & node /app/src/server/index.js &
APP_PID=$! APP_PID=$!
@@ -71,6 +111,7 @@ shutdown() {
log "shutdown requested" log "shutdown requested"
kill "$APP_PID" 2>/dev/null || true kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true
cleanup_proxy_firewall
cleanup_tproxy cleanup_tproxy
} }
@@ -78,5 +119,6 @@ trap 'shutdown; exit 0' SIGTERM SIGINT
wait "$APP_PID" wait "$APP_PID"
STATUS=$? STATUS=$?
cleanup_proxy_firewall
cleanup_tproxy cleanup_tproxy
exit "$STATUS" exit "$STATUS"

View File

@@ -15,6 +15,7 @@ export const settings = {
statePath: path.join(dataDir, "state.json"), statePath: path.join(dataDir, "state.json"),
customRulesPath: path.join(dataDir, "custom-rules.json"), customRulesPath: path.join(dataDir, "custom-rules.json"),
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"), customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.json"), deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
hwidPath: path.join(dataDir, "hwid"), 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, readSingboxConfig,
removeSingboxConfig, removeSingboxConfig,
} from "./singbox.js"; } from "./singbox.js";
import {
legacyDeviceRulesFromProfiles,
readDeviceProfiles,
writeDeviceProfiles,
} from "./devices.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.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) ──────────────────────────────────────────── // ─── Direct bypass cache (ipset) ────────────────────────────────────────────
const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass"; 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_TTL = process.env.DIRECT_BYPASS_TTL || "3600";
const DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true";
const IPSET_AVAILABLE = (() => { const IPSET_AVAILABLE = (() => {
try { try {
spawnSync("ipset", ["version"], { timeout: 1000 }); const result = spawnSync("ipset", ["version"], { timeout: 1000 });
return true; return !result.error && result.status === 0;
} catch { } catch {
return false; return false;
} }
@@ -38,7 +44,7 @@ const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/;
let directBypassCount = 0; let directBypassCount = 0;
function addToDirectBypass(ip) { function addToDirectBypass(ip) {
if (!IPSET_AVAILABLE || !IP_RE.test(ip)) return; if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE || !IP_RE.test(ip)) return;
try { try {
spawnSync( spawnSync(
"ipset", "ipset",
@@ -60,7 +66,7 @@ function flushDirectBypass() {
} }
function listDirectBypass() { function listDirectBypass() {
if (!IPSET_AVAILABLE) return []; if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE) return [];
try { try {
const result = spawnSync( const result = spawnSync(
"ipset", "ipset",
@@ -478,7 +484,7 @@ async function startSingbox() {
function publicState() { function publicState() {
const state = readJson(settings.statePath, {}); const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []); const customRules = readJson(settings.customRulesPath, []);
const deviceRules = readJson(settings.deviceRulesPath, []); const deviceProfiles = readDeviceProfiles();
const { subscriptionUrl, ...rest } = state; const { subscriptionUrl, ...rest } = state;
return { return {
mode: "gateway", mode: "gateway",
@@ -492,12 +498,16 @@ function publicState() {
subscriptionHost: maskSubscriptionUrl(subscriptionUrl), subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl), hasSubscription: Boolean(subscriptionUrl),
customRules, customRules,
deviceRules, devicesConfig: deviceProfiles,
devices: deviceProfiles.devices,
deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
appliedHistory: state.appliedHistory || [], appliedHistory: state.appliedHistory || [],
rulesUpdatedAt: state.rulesUpdatedAt || null, rulesUpdatedAt: state.rulesUpdatedAt || null,
devicesUpdatedAt: state.devicesUpdatedAt || null,
rulesAppliedAt: state.rulesAppliedAt || null, rulesAppliedAt: state.rulesAppliedAt || null,
bypassMode: Boolean(state.bypassMode), bypassMode: Boolean(state.bypassMode),
directBypassCount, directBypassCount,
directBypassEnabled: DIRECT_BYPASS_CACHE,
directBypassAvailable: IPSET_AVAILABLE, directBypassAvailable: IPSET_AVAILABLE,
...rest, ...rest,
}; };
@@ -739,17 +749,69 @@ async function handleApi(req, res) {
} }
if (req.method === "GET" && req.url === "/api/device-rules") { if (req.method === "GET" && req.url === "/api/device-rules") {
const deviceProfiles = readDeviceProfiles();
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
deviceRules: readJson(settings.deviceRulesPath, []), deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
}); });
} }
if (req.method === "PUT" && req.url === "/api/device-rules") { if (req.method === "PUT" && req.url === "/api/device-rules") {
const body = await readBody(req); const body = await readBody(req);
const rules = normalizeDeviceRules(body.deviceRules); const rules = normalizeDeviceRules(body.deviceRules);
writeJson(settings.deviceRulesPath, rules); const devices = [];
return sendJson(res, 200, { success: true, deviceRules: rules }); 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") { if (req.method === "GET" && req.url === "/api/rule-sets") {
@@ -954,6 +1016,8 @@ async function handleApi(req, res) {
? Number(body.port) ? Number(body.port)
: undefined; : undefined;
const network = String(body.network || "").trim() || 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) { if (!host && !ip) {
return sendJson(res, 400, { return sendJson(res, 400, {
@@ -972,12 +1036,12 @@ async function handleApi(req, res) {
} }
const rules = readJson(settings.customRulesPath, []); const rules = readJson(settings.customRulesPath, []);
const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {}); const state = readJson(settings.statePath, {});
const vpnTag = state.selectedTag || "vpn-out"; 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, routingRuDirect: settings.routingRuDirect,
vpnTag, vpnTag,
deviceProfiles: readDeviceProfiles(),
}); });
return sendJson(res, 200, { return sendJson(res, 200, {

View File

@@ -4,6 +4,7 @@
// мы не можем точно сказать, попадает ли IP/домен в RU. // мы не можем точно сказать, попадает ли IP/домен в RU.
import net from "node:net"; import net from "node:net";
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
function ipv4ToInt(ip) { function ipv4ToInt(ip) {
const parts = ip.split(".").map((x) => Number.parseInt(x, 10)); 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)); 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) { function hostMatchesDomain(host, domain) {
if (!host || !domain) return false; if (!host || !domain) return false;
return host.toLowerCase() === domain.toLowerCase(); return host.toLowerCase() === domain.toLowerCase();
@@ -116,8 +139,25 @@ function ruleMatches(rule, target) {
* @param {object} options { routingRuDirect, vpnTag } * @param {object} options { routingRuDirect, vpnTag }
*/ */
export function matchRoute(target, customRules, options = {}) { 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 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 // 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) { 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) { for (let i = 0; i < rules.length; i += 1) {
const rule = rules[i]; const rule = rules[i];
if (ruleMatches(rule, target)) { if (ruleMatches(rule, target)) {
@@ -142,30 +182,68 @@ export function matchRoute(target, customRules, options = {}) {
ruleId: rule.id, ruleId: rule.id,
ruleName: rule.name, ruleName: rule.name,
outbound, outbound,
reason: "Совпадение по custom-правилу", reason: "Совпадение по global custom rule",
}; };
} }
} }
// 3. RU direct (geoip/geosite) — мы не знаем точно, скажем "может сработать" // 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
if (routingRuDirect) { if (routingRuDirect && likelyRuHost(target.host)) {
return { return {
matched: "fallback-ru-or-vpn", matched: "geo",
ruleIndex: -2, ruleIndex: -2,
ruleName: "geoip-ru / geosite-category-ru → direct, иначе VPN", ruleName: "geosite-category-ru → direct",
outbound: `direct или ${vpnTag}`, outbound: "direct",
reason: reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
"Если домен/IP попадает в geoip-ru или geosite-category-ru — direct; иначе — VPN. Без локальной базы точно не определить.",
}; };
} }
// 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 { return {
matched: "final", matched: "final",
ruleIndex: -3, ruleIndex: -3,
ruleName: "final", ruleName: "final",
outbound: vpnTag, outbound: "direct",
reason: "Не сработало ни одно правило — пойдёт через VPN", reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
}; };
} }

View File

@@ -1,6 +1,12 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { settings } from "./config.js"; import { settings } from "./config.js";
import {
MIXED_INBOUND,
TPROXY_INBOUND,
normalizeCidr,
readDeviceProfiles,
} from "./devices.js";
const PROXY_TYPES = new Set([ const PROXY_TYPES = new Set([
"vless", "vless",
@@ -100,11 +106,11 @@ function parsePorts(values) {
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535); .filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
} }
function toSingboxRule(customRule, vpnTag) { function toSingboxRule(customRule, vpnTag, baseRule = {}) {
if (!customRule?.enabled) return null; if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null; if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = {}; const rule = { ...baseRule };
const domains = uniqueClean(customRule.domains); const domains = uniqueClean(customRule.domains);
const domainSuffixes = uniqueClean(customRule.domainSuffixes); const domainSuffixes = uniqueClean(customRule.domainSuffixes);
const domainKeywords = uniqueClean(customRule.domainKeywords); const domainKeywords = uniqueClean(customRule.domainKeywords);
@@ -140,47 +146,57 @@ function toSingboxRule(customRule, vpnTag) {
return rule; return rule;
} }
function customRouteRules(customRules, vpnTag) { function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : []) return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag)) .map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean); .filter(Boolean);
} }
// ─── Device rules (маршрутизация по source IP) ────────────────────────────── // ─── Device rules (маршрутизация по source IP) ──────────────────────────────
function readDeviceRules() { function modeOutbound(mode, vpnTag) {
try { if (mode === "vpn") return vpnTag;
if (!fs.existsSync(settings.deviceRulesPath)) return []; if (mode === "direct" || mode === "block") return mode;
const data = JSON.parse(fs.readFileSync(settings.deviceRulesPath, "utf8")); return null;
return Array.isArray(data) ? data : [];
} catch {
return [];
}
} }
function normalizeCidr(ip) { function deviceDefaultRouteRule(device, vpnTag) {
return ip.includes("/") ? ip : `${ip}/32`;
}
function toDeviceRouteRule(device, vpnTag) {
if (!device?.enabled) return null; if (!device?.enabled) return null;
const cidrs = (Array.isArray(device.sourceIps) ? device.sourceIps : []) const outbound = modeOutbound(device.mode, vpnTag);
.map((ip) => normalizeCidr(ip.trim())) if (!outbound) return null;
.filter(Boolean);
if (!cidrs.length) return null; const cidr = normalizeCidr(device.ip);
const outbound = if (!cidr) return null;
device.outbound === "vpn" ? vpnTag : device.outbound || "direct";
return { source_ip_cidr: cidrs, outbound }; return {
inbound: [TPROXY_INBOUND],
source_ip_cidr: [cidr],
outbound,
};
} }
function deviceRouteRules(deviceRules, vpnTag) { function deviceDefaultRouteRules(devices, vpnTag) {
return (Array.isArray(deviceRules) ? deviceRules : []) return (Array.isArray(devices) ? devices : [])
.map((d) => toDeviceRouteRule(d, vpnTag)) .map((device) => deviceDefaultRouteRule(device, vpnTag))
.filter(Boolean); .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) { function routeRules(customRules, vpnTag) {
const deviceRules = readDeviceRules(); const deviceProfiles = readDeviceProfiles();
const rules = [ const rules = [
{ {
ip_is_private: true, ip_is_private: true,
@@ -188,17 +204,28 @@ function routeRules(customRules, vpnTag) {
}, },
]; ];
// Правила по устройствам (source IP) — выполняются ДО правил по назначению // Global rules apply to every inbound before contextual fallbacks.
rules.push(...deviceRouteRules(deviceRules, vpnTag));
rules.push(...customRouteRules(customRules, vpnTag)); rules.push(...customRouteRules(customRules, vpnTag));
if (settings.routingRuDirect) { const ruRule = ruDirectRule();
rules.push({ if (ruRule) rules.push(ruRule);
rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: "direct", // 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; return rules;
} }
@@ -263,7 +290,7 @@ export function buildGatewayConfig(
rules: bypassAll rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }] ? [{ ip_is_private: true, outbound: "direct" }]
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), : routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
final: bypassAll ? "direct" : vpnOutbound.tag, final: "direct",
auto_detect_interface: true, auto_detect_interface: true,
}, },
}; };

View File

@@ -27,7 +27,11 @@ function App() {
const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = useState([]); const [customRules, setCustomRules] = useState([]);
const [deviceRules, setDeviceRules] = useState([]); const [devicesConfig, setDevicesConfig] = useState({
defaultTransparentMode: 'direct',
proxyDefaultMode: 'vpn',
devices: [],
});
const [selectedTag, setSelectedTag] = useState(''); const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState(''); const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -68,7 +72,11 @@ function App() {
setState(data); setState(data);
setServers(data.servers || []); setServers(data.servers || []);
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []); if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
setDeviceRules(data.deviceRules || []); setDevicesConfig(data.devicesConfig || {
defaultTransparentMode: 'direct',
proxyDefaultMode: 'vpn',
devices: data.devices || [],
});
setSelectedTag((prev) => prev || data.selectedTag || ''); setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || '');
} }
@@ -195,35 +203,55 @@ function App() {
}); });
} }
// === Device Rules === // === Devices ===
async function saveDeviceRules(rules) { async function saveDevicesConfig(nextConfig) {
try { try {
const data = await api.deviceRules.save(rules); const data = await api.devices.save(nextConfig);
setDeviceRules(data.deviceRules || rules); setDevicesConfig({
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'direct',
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
devices: data.devices || [],
});
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
} catch (err) { } catch (err) {
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message }); pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
} }
} }
function addDevice() { function addDevice() {
const next = [ const nextConfig = {
...deviceRules, ...devicesConfig,
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, sourceIps: [], outbound: 'direct' }, devices: [
]; ...devicesConfig.devices,
setDeviceRules(next); { id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
saveDeviceRules(next); ],
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
} }
function updateDevice(id, patch) { function updateDevice(id, patch) {
const next = deviceRules.map((d) => (d.id === id ? { ...d, ...patch } : d)); const nextConfig = {
setDeviceRules(next); ...devicesConfig,
saveDeviceRules(next); devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
} }
function removeDevice(id) { function removeDevice(id) {
const next = deviceRules.filter((d) => d.id !== id); const nextConfig = {
setDeviceRules(next); ...devicesConfig,
saveDeviceRules(next); 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 === // === Rules CRUD ===
@@ -326,11 +354,16 @@ function App() {
); );
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving'; const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyDevices = Boolean(
state?.devicesUpdatedAt &&
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
);
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag; const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
const dirty = dirtyRules || dirtyServer; const dirtyRouting = dirtyRules || dirtyDevices;
const dirty = dirtyRouting || dirtyServer;
const sidebarBadges = { const sidebarBadges = {
routing: dirtyRules ? { kind: 'warn', text: '●' } : null, routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
servers: dirtyServer ? { kind: 'warn', text: '●' } : null, servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null, settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
}; };
@@ -391,13 +424,14 @@ function App() {
onRemove={removeRule} onRemove={removeRule}
onSaveNow={saveRulesNow} onSaveNow={saveRulesNow}
onReorder={reorderRules} onReorder={reorderRules}
deviceRules={deviceRules} devicesConfig={devicesConfig}
onUpdateDeviceDefaults={updateDeviceDefaults}
onAddDevice={addDevice} onAddDevice={addDevice}
onUpdateDevice={updateDevice} onUpdateDevice={updateDevice}
onRemoveDevice={removeDevice} onRemoveDevice={removeDevice}
/> />
)} )}
{page === 'logs' && <LogsPage deviceRules={deviceRules} />} {page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && ( {page === 'settings' && (
<SettingsPage <SettingsPage
state={state} state={state}
@@ -413,19 +447,22 @@ function App() {
)} )}
{/* Sticky bar — для routing/servers */} {/* Sticky bar — для routing/servers */}
{(page === 'routing' && rulesSaveStatus !== 'saved') && ( {(page === 'routing' && dirtyRouting) && (
<div className="sticky-bar"> <div className="sticky-bar">
<div className="flex"> <div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} /> <span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
<strong> <strong>
{rulesSaveStatus === 'saving' && 'Сохраняем…'} {rulesSaveStatus === 'saving' && 'Сохраняем…'}
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'} {rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
{rulesSaveStatus === 'error' && 'Ошибка сохранения'} {rulesSaveStatus === 'error' && 'Ошибка сохранения'}
</strong> </strong>
<small className="muted">Изменения сохранены, но конфиг не пересобран. Применить на странице «Серверы».</small> <small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
</div> </div>
<div className="btn-group"> <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 && ( {state?.selectedTag && (
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}> <button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
Применить config Применить 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: { ruleSets: {
get: () => request("/api/rule-sets"), get: () => request("/api/rule-sets"),
save: (ruleSets) => save: (ruleSets) =>
@@ -93,10 +102,10 @@ export const api = {
}, },
route: { route: {
check: ({ host, ip, port, network }) => check: ({ host, ip, port, network, sourceIp, inbound }) =>
request("/api/route/check", { request("/api/route/check", {
method: "POST", 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' }, other: { cls: '', label: 'other' },
}; };
function getDeviceName(sourceIp, deviceRules) { function getDeviceName(sourceIp, devices) {
if (!sourceIp || !deviceRules?.length) return null; if (!sourceIp || !devices?.length) return null;
for (const d of deviceRules) { for (const d of devices) {
if (d.enabled === false) continue; if (d.enabled === false) continue;
for (const ip of (d.sourceIps || [])) { const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
const plain = ip.endsWith('/32') ? ip.slice(0, -3) : ip; const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name; if (plain === sourceIp) return d.name;
}
} }
return null; return null;
} }
@@ -64,7 +63,7 @@ function groupTraffic(list, sortBy = 'time') {
return arr.sort((a, b) => b._lastTs - a._lastTs); return arr.sort((a, b) => b._lastTs - a._lastTs);
} }
function TrafficTab({ deviceRules = [] }) { function TrafficTab({ devices = [] }) {
const [traffic, setTraffic] = useState([]); const [traffic, setTraffic] = useState([]);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all'); // all | direct | vpn | block const [filter, setFilter] = useState('all'); // all | direct | vpn | block
@@ -104,11 +103,11 @@ function TrafficTab({ deviceRules = [] }) {
e.outbound?.toLowerCase().includes(s) || e.outbound?.toLowerCase().includes(s) ||
e.matchedRule?.toLowerCase().includes(s) || e.matchedRule?.toLowerCase().includes(s) ||
e.sourceIp?.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; return grouped ? groupTraffic(list, sortBy) : list;
}, [traffic, filter, search, grouped, sortBy]); }, [traffic, filter, search, grouped, sortBy, devices]);
useEffect(() => { useEffect(() => {
if (!autoscroll || !containerRef.current) return; if (!autoscroll || !containerRef.current) return;
@@ -183,7 +182,7 @@ function TrafficTab({ deviceRules = [] }) {
<tbody> <tbody>
{filtered.map((e, i) => { {filtered.map((e, i) => {
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other; const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
const deviceName = getDeviceName(e.sourceIp, deviceRules); const deviceName = getDeviceName(e.sourceIp, devices);
return ( return (
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}> <tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td> <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 [tab, setTab] = useState('traffic'); // traffic | logs
const [entries, setEntries] = useState([]); const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
@@ -276,7 +275,7 @@ export function LogsPage({ deviceRules = [] }) {
</div> </div>
</div> </div>
{tab === 'traffic' && <TrafficTab deviceRules={deviceRules} />} {tab === 'traffic' && <TrafficTab devices={devices} />}
{tab === 'logs' && ( {tab === 'logs' && (
<> <>

View File

@@ -136,7 +136,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) {
const rules = state?.customRules || []; const rules = state?.customRules || [];
const enabled = rules.filter((r) => r.enabled).length; const enabled = rules.filter((r) => r.enabled).length;
const cacheCount = state?.directBypassCount || 0; 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 ( return (
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
@@ -148,8 +150,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) {
{state?.routingRuDirect && ( {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">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">Global 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">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 && ( {cacheAvailable && (
<div className="row"> <div className="row">
<span className="key">Direct bypass cache</span> <span className="key">Direct bypass cache</span>

View File

@@ -5,6 +5,8 @@ export function RouteChecker() {
const [host, setHost] = useState(''); const [host, setHost] = useState('');
const [port, setPort] = useState('443'); const [port, setPort] = useState('443');
const [network, setNetwork] = useState('tcp'); const [network, setNetwork] = useState('tcp');
const [sourceIp, setSourceIp] = useState('');
const [inbound, setInbound] = useState('tproxy-in');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -14,7 +16,13 @@ export function RouteChecker() {
setError(''); setError('');
setResult(null); setResult(null);
try { 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); setResult(data);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -52,6 +60,17 @@ export function RouteChecker() {
<option value="tcp">tcp</option> <option value="tcp">tcp</option>
<option value="udp">udp</option> <option value="udp">udp</option>
</select> </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> <button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div> </div>

View File

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