Add UI-controlled TProxy bypass for devices
This commit is contained in:
@@ -75,7 +75,7 @@ docker compose -f docker-compose.client.yml restart
|
|||||||
▼
|
▼
|
||||||
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||||
│
|
│
|
||||||
├─ source bypass CIDR → RETURN ← устройства мимо sing-box
|
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
|
||||||
├─ 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
|
||||||
@@ -127,9 +127,10 @@ ip route replace local 0.0.0.0/0 dev lo table 100
|
|||||||
|
|
||||||
# Цепочка iptables (порядок правил — критичен)
|
# Цепочка iptables (порядок правил — критичен)
|
||||||
iptables -t mangle -N VPN_PROXY_TPROXY
|
iptables -t mangle -N VPN_PROXY_TPROXY
|
||||||
|
iptables -t mangle -N VPN_PROXY_TPROXY_SOURCE_BYPASS
|
||||||
-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 # уже помеченные пакеты
|
||||||
-s 192.168.50.25/32 → RETURN # TPROXY_BYPASS_SOURCE_CIDRS
|
-j VPN_PROXY_TPROXY_SOURCE_BYPASS → ACCEPT # source bypass до sing-box
|
||||||
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
-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
|
||||||
@@ -140,6 +141,9 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
|||||||
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||||
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||||
|
|
||||||
|
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
|
||||||
|
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
|
||||||
|
|
||||||
### 2. Маршрутизация внутри sing-box
|
### 2. Маршрутизация внутри sing-box
|
||||||
|
|
||||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||||
@@ -380,6 +384,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
|||||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||||
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||||
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
|
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
|
||||||
|
| `TPROXY_SOURCE_BYPASS_CHAIN` | `${TPROXY_CHAIN}_SOURCE_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
|
||||||
| `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 |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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}"
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-${TPROXY_CHAIN}_SOURCE_BYPASS}"
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
@@ -39,6 +40,8 @@ cleanup_tproxy() {
|
|||||||
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
# ipset не чистим при завершении — TTL сам истечёт
|
# ipset не чистим при завершении — TTL сам истечёт
|
||||||
@@ -82,12 +85,14 @@ setup_tproxy() {
|
|||||||
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
|
||||||
ipt -t mangle -N "$TPROXY_CHAIN"
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
||||||
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
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
|
|
||||||
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
|
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -s "$cidr" -j RETURN
|
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ TPROXY_PORT=7895
|
|||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_TPROXY_SOURCE_BYPASS
|
||||||
TPROXY_BYPASS_SOURCE_CIDRS=
|
TPROXY_BYPASS_SOURCE_CIDRS=
|
||||||
ROUTING_RU_DIRECT=true
|
ROUTING_RU_DIRECT=true
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const settings = {
|
|||||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||||
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
|
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
|
||||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||||
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
|
tproxySourceBypassChain:
|
||||||
|
process.env.TPROXY_SOURCE_BYPASS_CHAIN ||
|
||||||
|
`${process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY"}_SOURCE_BYPASS`,
|
||||||
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || "/app/dist",
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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";
|
||||||
|
|
||||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
|
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
|
||||||
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||||
export const DEFAULT_PROXY_MODE = "vpn";
|
export const DEFAULT_PROXY_MODE = "vpn";
|
||||||
@@ -27,7 +27,6 @@ function writeJson(filePath, value) {
|
|||||||
|
|
||||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||||
const value = String(mode || "").trim().toLowerCase();
|
const value = String(mode || "").trim().toLowerCase();
|
||||||
if (value === "bypass") return "direct";
|
|
||||||
return DEVICE_MODES.has(value) ? value : fallback;
|
return DEVICE_MODES.has(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
buildSharedProxyInfo,
|
buildSharedProxyInfo,
|
||||||
checkSharedProxyGateway,
|
checkSharedProxyGateway,
|
||||||
} from "./sharedProxy.js";
|
} from "./sharedProxy.js";
|
||||||
|
import {
|
||||||
|
sourceBypassCidrs,
|
||||||
|
syncTproxySourceBypass,
|
||||||
|
} from "./tproxySourceBypass.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";
|
||||||
|
|
||||||
@@ -634,6 +638,7 @@ function publicState() {
|
|||||||
directBypassCount,
|
directBypassCount,
|
||||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||||
directBypassAvailable: IPSET_AVAILABLE,
|
directBypassAvailable: IPSET_AVAILABLE,
|
||||||
|
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -970,6 +975,13 @@ async function handleApi(req, res) {
|
|||||||
devices: body.devices,
|
devices: body.devices,
|
||||||
};
|
};
|
||||||
const profiles = writeDeviceProfiles(input);
|
const profiles = writeDeviceProfiles(input);
|
||||||
|
const sourceBypassResult = syncTproxySourceBypass(profiles);
|
||||||
|
if (!sourceBypassResult.success) {
|
||||||
|
pushLog(
|
||||||
|
"warning",
|
||||||
|
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const prevState = readJson(settings.statePath, {});
|
const prevState = readJson(settings.statePath, {});
|
||||||
const devicesUpdatedAt = new Date().toISOString();
|
const devicesUpdatedAt = new Date().toISOString();
|
||||||
writeJson(settings.statePath, {
|
writeJson(settings.statePath, {
|
||||||
@@ -979,6 +991,8 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
...profiles,
|
...profiles,
|
||||||
|
sourceBypassCidrs: sourceBypassCidrs(profiles),
|
||||||
|
sourceBypassResult,
|
||||||
devicesUpdatedAt,
|
devicesUpdatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1541,6 +1555,14 @@ process.on("SIGINT", async () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
|
||||||
|
if (!sourceBypassStartup.success) {
|
||||||
|
pushLog(
|
||||||
|
"warning",
|
||||||
|
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// При старте пробуем подхватить уже запущенный sing-box
|
// При старте пробуем подхватить уже запущенный sing-box
|
||||||
const existingPid = readSingboxPid();
|
const existingPid = readSingboxPid();
|
||||||
if (existingPid && isPidAlive(existingPid)) {
|
if (existingPid && isPidAlive(existingPid)) {
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ export function matchRoute(target, customRules, options = {}) {
|
|||||||
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
inbound === TPROXY_INBOUND &&
|
||||||
|
matchedDevice &&
|
||||||
|
matchedDevice.mode === "bypass"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
matched: "kernel-bypass",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleId: matchedDevice.id,
|
||||||
|
ruleName: `${matchedDevice.name} -> bypass TProxy`,
|
||||||
|
outbound: "direct",
|
||||||
|
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 1. private IP → direct
|
// 1. private IP → direct
|
||||||
if (target.ip && isPrivateIp(target.ip)) {
|
if (target.ip && isPrivateIp(target.ip)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
75
src/server/tproxySourceBypass.js
Normal file
75
src/server/tproxySourceBypass.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
import { deviceCidrs, normalizeCidr } from "./devices.js";
|
||||||
|
|
||||||
|
function splitCidrs(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((item) => normalizeCidr(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(list) {
|
||||||
|
return [...new Set(list)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceBypassCidrs(
|
||||||
|
profiles,
|
||||||
|
envCidrs = process.env.TPROXY_BYPASS_SOURCE_CIDRS || "",
|
||||||
|
) {
|
||||||
|
return unique([
|
||||||
|
...splitCidrs(envCidrs),
|
||||||
|
...deviceCidrs(profiles?.devices || [], "bypass"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSourceBypassIptablesCommands(
|
||||||
|
cidrs,
|
||||||
|
{ chain = settings.tproxySourceBypassChain } = {},
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
["-w", "-t", "mangle", "-F", chain],
|
||||||
|
...cidrs.map((cidr) => [
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
"mangle",
|
||||||
|
"-A",
|
||||||
|
chain,
|
||||||
|
"-s",
|
||||||
|
cidr,
|
||||||
|
"-j",
|
||||||
|
"ACCEPT",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTproxySourceBypass(profiles, options = {}) {
|
||||||
|
if (settings.appMode !== "gateway") {
|
||||||
|
return { success: true, skipped: true, cidrs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = options.chain || settings.tproxySourceBypassChain;
|
||||||
|
const cidrs = sourceBypassCidrs(
|
||||||
|
profiles,
|
||||||
|
options.envCidrs ?? process.env.TPROXY_BYPASS_SOURCE_CIDRS,
|
||||||
|
);
|
||||||
|
const commands = buildSourceBypassIptablesCommands(cidrs, { chain });
|
||||||
|
|
||||||
|
for (const args of commands) {
|
||||||
|
const result = spawnSync("iptables", args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
cidrs,
|
||||||
|
error:
|
||||||
|
result.error?.message ||
|
||||||
|
(result.stderr || result.stdout || "iptables command failed").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, cidrs };
|
||||||
|
}
|
||||||
@@ -224,7 +224,19 @@ function App() {
|
|||||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||||
devices: data.devices || [],
|
devices: data.devices || [],
|
||||||
});
|
});
|
||||||
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
|
setState((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
devicesUpdatedAt: data.devicesUpdatedAt,
|
||||||
|
sourceBypassCidrs: data.sourceBypassCidrs,
|
||||||
|
} : prev);
|
||||||
|
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
|
||||||
|
pushToast({
|
||||||
|
kind: 'warning',
|
||||||
|
title: 'Bypass сохранён, но не применён',
|
||||||
|
message: data.sourceBypassResult.error,
|
||||||
|
duration: 7000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const OUTBOUND_KIND = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEVICE_MODES = {
|
const DEVICE_MODES = {
|
||||||
|
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
|
||||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||||
@@ -28,6 +29,7 @@ const DEVICE_MODES = {
|
|||||||
function DeviceModeSelect({ value, onChange }) {
|
function DeviceModeSelect({ value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="bypass">bypass TProxy</option>
|
||||||
<option value="direct">direct</option>
|
<option value="direct">direct</option>
|
||||||
<option value="vpn">VPN</option>
|
<option value="vpn">VPN</option>
|
||||||
<option value="rules">default</option>
|
<option value="rules">default</option>
|
||||||
@@ -46,7 +48,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Устройства</h2>
|
<h2>Устройства</h2>
|
||||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы — fallback после global rules.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="btn-group">
|
<div className="btn-group">
|
||||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||||
|
|||||||
108
test/server/device-bypass-mode.test.js
Normal file
108
test/server/device-bypass-mode.test.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const {
|
||||||
|
deviceCidrs,
|
||||||
|
normalizeDeviceProfiles,
|
||||||
|
} = await import("../../src/server/devices.js");
|
||||||
|
const { matchRoute } = await import("../../src/server/routeMatcher.js");
|
||||||
|
const {
|
||||||
|
sourceBypassCidrs,
|
||||||
|
buildSourceBypassIptablesCommands,
|
||||||
|
} = await import("../../src/server/tproxySourceBypass.js");
|
||||||
|
|
||||||
|
test("device profiles preserve bypass mode for kernel-level TProxy bypass", () => {
|
||||||
|
const profiles = normalizeDeviceProfiles({
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: "pc",
|
||||||
|
name: "PC",
|
||||||
|
enabled: true,
|
||||||
|
ip: "192.168.50.25",
|
||||||
|
mode: "bypass",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(profiles.devices[0].mode, "bypass");
|
||||||
|
assert.deepEqual(deviceCidrs(profiles.devices, "bypass"), [
|
||||||
|
"192.168.50.25/32",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("route checker reports transparent bypass before sing-box rules", () => {
|
||||||
|
const result = matchRoute(
|
||||||
|
{
|
||||||
|
host: "example.com",
|
||||||
|
ip: "93.184.216.34",
|
||||||
|
sourceIp: "192.168.50.25",
|
||||||
|
inbound: "tproxy-in",
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "vpn-all",
|
||||||
|
enabled: true,
|
||||||
|
name: "VPN all",
|
||||||
|
domains: ["example.com"],
|
||||||
|
outbound: "vpn",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
vpnTag: "test-vpn",
|
||||||
|
deviceProfiles: {
|
||||||
|
defaultTransparentMode: "vpn",
|
||||||
|
proxyDefaultMode: "vpn",
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: "pc",
|
||||||
|
name: "PC",
|
||||||
|
enabled: true,
|
||||||
|
ip: "192.168.50.25",
|
||||||
|
mode: "bypass",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.matched, "kernel-bypass");
|
||||||
|
assert.equal(result.ruleName, "PC -> bypass TProxy");
|
||||||
|
assert.equal(result.outbound, "direct");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source bypass sync combines env CIDRs and bypass-mode devices", () => {
|
||||||
|
const cidrs = sourceBypassCidrs(
|
||||||
|
{
|
||||||
|
devices: [
|
||||||
|
{ enabled: true, ip: "192.168.50.25", mode: "bypass" },
|
||||||
|
{ enabled: false, ip: "192.168.50.26", mode: "bypass" },
|
||||||
|
{ enabled: true, ip: "192.168.50.27", mode: "direct" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"192.168.50.30/32",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(cidrs, ["192.168.50.30/32", "192.168.50.25/32"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source bypass iptables commands use ACCEPT inside the managed subchain", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildSourceBypassIptablesCommands(["192.168.50.25/32"], {
|
||||||
|
chain: "VPN_PROXY_SOURCE_BYPASS",
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
["-w", "-t", "mangle", "-F", "VPN_PROXY_SOURCE_BYPASS"],
|
||||||
|
[
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
"mangle",
|
||||||
|
"-A",
|
||||||
|
"VPN_PROXY_SOURCE_BYPASS",
|
||||||
|
"-s",
|
||||||
|
"192.168.50.25/32",
|
||||||
|
"-j",
|
||||||
|
"ACCEPT",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -63,12 +63,17 @@ exit 0
|
|||||||
const commands = fs.readFileSync(commandLog, "utf8").trim().split("\n");
|
const commands = fs.readFileSync(commandLog, "utf8").trim().split("\n");
|
||||||
const sourceBypassIndex = commands.findIndex((line) =>
|
const sourceBypassIndex = commands.findIndex((line) =>
|
||||||
line.includes(
|
line.includes(
|
||||||
"iptables -w -t mangle -A VPN_PROXY_TPROXY -s 192.168.50.25/32 -j RETURN",
|
"iptables -w -t mangle -A VPN_PROXY_TPROXY_SOURCE_BYPASS -s 192.168.50.25/32 -j ACCEPT",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const secondSourceBypassIndex = commands.findIndex((line) =>
|
const secondSourceBypassIndex = commands.findIndex((line) =>
|
||||||
line.includes(
|
line.includes(
|
||||||
"iptables -w -t mangle -A VPN_PROXY_TPROXY -s 192.168.50.26/32 -j RETURN",
|
"iptables -w -t mangle -A VPN_PROXY_TPROXY_SOURCE_BYPASS -s 192.168.50.26/32 -j ACCEPT",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const sourceBypassJumpIndex = commands.findIndex((line) =>
|
||||||
|
line.includes(
|
||||||
|
"iptables -w -t mangle -A VPN_PROXY_TPROXY -j VPN_PROXY_TPROXY_SOURCE_BYPASS",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const directCacheIndex = commands.findIndex((line) =>
|
const directCacheIndex = commands.findIndex((line) =>
|
||||||
@@ -80,9 +85,9 @@ exit 0
|
|||||||
|
|
||||||
assert.notEqual(sourceBypassIndex, -1);
|
assert.notEqual(sourceBypassIndex, -1);
|
||||||
assert.notEqual(secondSourceBypassIndex, -1);
|
assert.notEqual(secondSourceBypassIndex, -1);
|
||||||
|
assert.notEqual(sourceBypassJumpIndex, -1);
|
||||||
assert.notEqual(directCacheIndex, -1);
|
assert.notEqual(directCacheIndex, -1);
|
||||||
assert.notEqual(tproxyIndex, -1);
|
assert.notEqual(tproxyIndex, -1);
|
||||||
assert.ok(sourceBypassIndex < directCacheIndex);
|
assert.ok(sourceBypassJumpIndex < directCacheIndex);
|
||||||
assert.ok(secondSourceBypassIndex < directCacheIndex);
|
assert.ok(sourceBypassJumpIndex < tproxyIndex);
|
||||||
assert.ok(sourceBypassIndex < tproxyIndex);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user