Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
86
README.md
86
README.md
@@ -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 трафика |
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
153
src/server/devices.js
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 отправляет напрямую",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user