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
|
||||
│
|
||||
├─ source bypass CIDR → RETURN ← устройства мимо sing-box
|
||||
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
|
||||
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
||||
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||
└─ TCP/UDP → TPROXY :7895
|
||||
@@ -127,9 +127,10 @@ ip route replace local 0.0.0.0/0 dev lo table 100
|
||||
|
||||
# Цепочка iptables (порядок правил — критичен)
|
||||
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 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
|
||||
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||
-p tcp → TPROXY :7895 mark 1
|
||||
@@ -140,6 +141,9 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
||||
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||
|
||||
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
|
||||
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
|
||||
|
||||
### 2. Маршрутизация внутри sing-box
|
||||
|
||||
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||
@@ -380,6 +384,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||
| `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) |
|
||||
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||
|
||||
@@ -5,6 +5,7 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
|
||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||
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_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||
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 -F "$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 route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||
# ipset не чистим при завершении — TTL сам истечёт
|
||||
@@ -82,12 +85,14 @@ setup_tproxy() {
|
||||
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_SOURCE_BYPASS_CHAIN"
|
||||
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
||||
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" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||
|
||||
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
|
||||
|
||||
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
||||
|
||||
@@ -47,6 +47,7 @@ TPROXY_PORT=7895
|
||||
TPROXY_MARK=1
|
||||
TPROXY_TABLE=100
|
||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_TPROXY_SOURCE_BYPASS
|
||||
TPROXY_BYPASS_SOURCE_CIDRS=
|
||||
ROUTING_RU_DIRECT=true
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -9,6 +9,10 @@ export const settings = {
|
||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
|
||||
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",
|
||||
dataDir,
|
||||
distDir: process.env.DIST_DIR || "/app/dist",
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
|
||||
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||
export const DEFAULT_PROXY_MODE = "vpn";
|
||||
@@ -27,7 +27,6 @@ function writeJson(filePath, value) {
|
||||
|
||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
if (value === "bypass") return "direct";
|
||||
return DEVICE_MODES.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
buildSharedProxyInfo,
|
||||
checkSharedProxyGateway,
|
||||
} from "./sharedProxy.js";
|
||||
import {
|
||||
sourceBypassCidrs,
|
||||
syncTproxySourceBypass,
|
||||
} from "./tproxySourceBypass.js";
|
||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||
import { tcpPing, resolveHost } from "./ping.js";
|
||||
|
||||
@@ -634,6 +638,7 @@ function publicState() {
|
||||
directBypassCount,
|
||||
directBypassEnabled: DIRECT_BYPASS_CACHE,
|
||||
directBypassAvailable: IPSET_AVAILABLE,
|
||||
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
@@ -970,6 +975,13 @@ async function handleApi(req, res) {
|
||||
devices: body.devices,
|
||||
};
|
||||
const profiles = writeDeviceProfiles(input);
|
||||
const sourceBypassResult = syncTproxySourceBypass(profiles);
|
||||
if (!sourceBypassResult.success) {
|
||||
pushLog(
|
||||
"warning",
|
||||
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
|
||||
);
|
||||
}
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
const devicesUpdatedAt = new Date().toISOString();
|
||||
writeJson(settings.statePath, {
|
||||
@@ -979,6 +991,8 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
...profiles,
|
||||
sourceBypassCidrs: sourceBypassCidrs(profiles),
|
||||
sourceBypassResult,
|
||||
devicesUpdatedAt,
|
||||
});
|
||||
}
|
||||
@@ -1541,6 +1555,14 @@ process.on("SIGINT", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
|
||||
if (!sourceBypassStartup.success) {
|
||||
pushLog(
|
||||
"warning",
|
||||
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// При старте пробуем подхватить уже запущенный sing-box
|
||||
const existingPid = readSingboxPid();
|
||||
if (existingPid && isPidAlive(existingPid)) {
|
||||
|
||||
@@ -159,6 +159,21 @@ export function matchRoute(target, customRules, options = {}) {
|
||||
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
|
||||
if (target.ip && isPrivateIp(target.ip)) {
|
||||
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',
|
||||
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) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const OUTBOUND_KIND = {
|
||||
};
|
||||
|
||||
const DEVICE_MODES = {
|
||||
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
|
||||
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' },
|
||||
@@ -28,6 +29,7 @@ const DEVICE_MODES = {
|
||||
function DeviceModeSelect({ value, onChange }) {
|
||||
return (
|
||||
<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="vpn">VPN</option>
|
||||
<option value="rules">default</option>
|
||||
@@ -46,7 +48,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h2>Устройства</h2>
|
||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
||||
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы — fallback после global rules.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<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 sourceBypassIndex = commands.findIndex((line) =>
|
||||
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) =>
|
||||
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) =>
|
||||
@@ -80,9 +85,9 @@ exit 0
|
||||
|
||||
assert.notEqual(sourceBypassIndex, -1);
|
||||
assert.notEqual(secondSourceBypassIndex, -1);
|
||||
assert.notEqual(sourceBypassJumpIndex, -1);
|
||||
assert.notEqual(directCacheIndex, -1);
|
||||
assert.notEqual(tproxyIndex, -1);
|
||||
assert.ok(sourceBypassIndex < directCacheIndex);
|
||||
assert.ok(secondSourceBypassIndex < directCacheIndex);
|
||||
assert.ok(sourceBypassIndex < tproxyIndex);
|
||||
assert.ok(sourceBypassJumpIndex < directCacheIndex);
|
||||
assert.ok(sourceBypassJumpIndex < tproxyIndex);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user