Add UI-controlled TProxy bypass for devices

This commit is contained in:
2026-05-24 13:38:52 +03:00
parent 0092ec4cde
commit ab6de6996f
12 changed files with 265 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View 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 };
}