From c3d3aaa699ba89ffa479aa3b1183473852ae1f1e Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 24 May 2026 14:00:09 +0300 Subject: [PATCH] Add kernel forwarding for bypassed devices --- README.md | 8 ++- entrypoint.sh | 31 +++++++++++ scripts/deploy-gateway.sh | 2 + src/server/config.js | 4 ++ src/server/tproxySourceBypass.js | 55 ++++++++++++++++++-- test/server/device-bypass-mode.test.js | 53 +++++++++++++++++++ test/server/entrypoint-tproxy-bypass.test.js | 29 +++++++++++ 7 files changed, 178 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7890da4..26516fd 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ docker compose -f docker-compose.client.yml restart iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY │ ├─ source bypass chain → ACCEPT ← устройства мимо sing-box + │ └─ FORWARD + MASQUERADE → обычный internet path ├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш ├─ приватные CIDR (RFC1918, ...) → RETURN └─ TCP/UDP → TPROXY :7895 @@ -128,6 +129,8 @@ 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_SRC_BYPASS +iptables -N VPN_PROXY_FWD_BYPASS +iptables -t nat -N VPN_PROXY_NAT_BYPASS -m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box -m mark --mark 1 → RETURN # уже помеченные пакеты -j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box @@ -142,7 +145,8 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY ipset-кэш намеренно **не** очищается — записи истекают по TTL. Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**. -Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ. +Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`. +Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ. ### 2. Маршрутизация внутри sing-box @@ -385,6 +389,8 @@ UI доступен на `http://:3456`. | `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` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass | +| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass | +| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass | | `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) | | `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct | | `LOG_LEVEL` | `info` | Уровень логов sing-box | diff --git a/entrypoint.sh b/entrypoint.sh index 1d5fbc9..ad40747 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,6 +6,8 @@ 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:-VPN_PROXY_SRC_BYPASS}" +TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}" +TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_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}" @@ -38,15 +40,32 @@ cleanup_proxy_firewall() { cleanup_tproxy() { log "cleanup tproxy rules" ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true + ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true + ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_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 + ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true + ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true + ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true + ipt -t nat -X "$TPROXY_SOURCE_NAT_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 сам истечёт } +enable_ip_forwarding() { + log "enable IPv4 forwarding for source bypass" + if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then + printf '1' > /proc/sys/net/ipv4/ip_forward || true + return + fi + if command -v sysctl >/dev/null 2>&1; then + sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true + fi +} + setup_direct_bypass_set() { if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then export DIRECT_BYPASS_CACHE @@ -80,19 +99,31 @@ setup_proxy_firewall() { setup_tproxy() { log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}" cleanup_tproxy + enable_ip_forwarding ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true 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" + ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN" + ipt -t nat -N "$TPROXY_SOURCE_NAT_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" + ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN" + ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN" + + for cidr in $BYPASS_CIDRS; do + ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN + done for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT + ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT + ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE done if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index dfaee62..abf7a4d 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -48,6 +48,8 @@ TPROXY_MARK=1 TPROXY_TABLE=100 TPROXY_CHAIN=VPN_PROXY_TPROXY TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS +TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS +TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS TPROXY_BYPASS_SOURCE_CIDRS= ROUTING_RU_DIRECT=true LOG_LEVEL=info diff --git a/src/server/config.js b/src/server/config.js index f64fed8..f64c6bf 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -12,6 +12,10 @@ export const settings = { tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", tproxySourceBypassChain: process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS", + tproxySourceForwardChain: + process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS", + tproxySourceNatChain: + process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, distDir: process.env.DIST_DIR || "/app/dist", diff --git a/src/server/tproxySourceBypass.js b/src/server/tproxySourceBypass.js index 3132830..0825402 100644 --- a/src/server/tproxySourceBypass.js +++ b/src/server/tproxySourceBypass.js @@ -2,6 +2,9 @@ import { spawnSync } from "node:child_process"; import { settings } from "./config.js"; import { deviceCidrs, normalizeCidr } from "./devices.js"; +const DEFAULT_NAT_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"; + function splitCidrs(value) { return String(value || "") .split(/[\s,]+/) @@ -25,10 +28,19 @@ export function sourceBypassCidrs( export function buildSourceBypassIptablesCommands( cidrs, - { chain = settings.tproxySourceBypassChain } = {}, + { + chain = settings.tproxySourceBypassChain, + forwardChain = settings.tproxySourceForwardChain, + natChain = settings.tproxySourceNatChain, + natBypassCidrs = splitCidrs( + process.env.BYPASS_CIDRS || DEFAULT_NAT_BYPASS_CIDRS, + ), + } = {}, ) { return [ ["-w", "-t", "mangle", "-F", chain], + ["-w", "-F", forwardChain], + ["-w", "-t", "nat", "-F", natChain], ...cidrs.map((cidr) => [ "-w", "-t", @@ -40,6 +52,44 @@ export function buildSourceBypassIptablesCommands( "-j", "ACCEPT", ]), + ...cidrs.flatMap((cidr) => [ + ["-w", "-A", forwardChain, "-s", cidr, "-j", "ACCEPT"], + [ + "-w", + "-A", + forwardChain, + "-d", + cidr, + "-m", + "conntrack", + "--ctstate", + "RELATED,ESTABLISHED", + "-j", + "ACCEPT", + ], + ]), + ...natBypassCidrs.map((cidr) => [ + "-w", + "-t", + "nat", + "-A", + natChain, + "-d", + cidr, + "-j", + "RETURN", + ]), + ...cidrs.map((cidr) => [ + "-w", + "-t", + "nat", + "-A", + natChain, + "-s", + cidr, + "-j", + "MASQUERADE", + ]), ]; } @@ -48,12 +98,11 @@ export function syncTproxySourceBypass(profiles, options = {}) { 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 }); + const commands = buildSourceBypassIptablesCommands(cidrs, options); for (const args of commands) { const result = spawnSync("iptables", args, { diff --git a/test/server/device-bypass-mode.test.js b/test/server/device-bypass-mode.test.js index fe77a1f..3aa035c 100644 --- a/test/server/device-bypass-mode.test.js +++ b/test/server/device-bypass-mode.test.js @@ -14,7 +14,11 @@ const { settings } = await import("../../src/server/config.js"); test("default source bypass chain name fits iptables chain length limit", () => { assert.equal(settings.tproxySourceBypassChain, "VPN_PROXY_SRC_BYPASS"); + assert.equal(settings.tproxySourceForwardChain, "VPN_PROXY_FWD_BYPASS"); + assert.equal(settings.tproxySourceNatChain, "VPN_PROXY_NAT_BYPASS"); assert.ok(settings.tproxySourceBypassChain.length <= 28); + assert.ok(settings.tproxySourceForwardChain.length <= 28); + assert.ok(settings.tproxySourceNatChain.length <= 28); }); test("device profiles preserve bypass mode for kernel-level TProxy bypass", () => { @@ -95,9 +99,14 @@ test("source bypass iptables commands use ACCEPT inside the managed subchain", ( assert.deepEqual( buildSourceBypassIptablesCommands(["192.168.50.25/32"], { chain: "VPN_PROXY_SOURCE_BYPASS", + forwardChain: "VPN_PROXY_FWD_BYPASS", + natChain: "VPN_PROXY_NAT_BYPASS", + natBypassCidrs: ["10.0.0.0/8"], }), [ ["-w", "-t", "mangle", "-F", "VPN_PROXY_SOURCE_BYPASS"], + ["-w", "-F", "VPN_PROXY_FWD_BYPASS"], + ["-w", "-t", "nat", "-F", "VPN_PROXY_NAT_BYPASS"], [ "-w", "-t", @@ -109,6 +118,50 @@ test("source bypass iptables commands use ACCEPT inside the managed subchain", ( "-j", "ACCEPT", ], + [ + "-w", + "-A", + "VPN_PROXY_FWD_BYPASS", + "-s", + "192.168.50.25/32", + "-j", + "ACCEPT", + ], + [ + "-w", + "-A", + "VPN_PROXY_FWD_BYPASS", + "-d", + "192.168.50.25/32", + "-m", + "conntrack", + "--ctstate", + "RELATED,ESTABLISHED", + "-j", + "ACCEPT", + ], + [ + "-w", + "-t", + "nat", + "-A", + "VPN_PROXY_NAT_BYPASS", + "-d", + "10.0.0.0/8", + "-j", + "RETURN", + ], + [ + "-w", + "-t", + "nat", + "-A", + "VPN_PROXY_NAT_BYPASS", + "-s", + "192.168.50.25/32", + "-j", + "MASQUERADE", + ], ], ); }); diff --git a/test/server/entrypoint-tproxy-bypass.test.js b/test/server/entrypoint-tproxy-bypass.test.js index 6a20c2c..2f40898 100644 --- a/test/server/entrypoint-tproxy-bypass.test.js +++ b/test/server/entrypoint-tproxy-bypass.test.js @@ -35,6 +35,13 @@ exit 0 `#!/usr/bin/env bash printf 'ipset %s\\n' "$*" >> "$COMMAND_LOG" exit 0 +`, + ); + writeExecutable( + path.join(tmp, "sysctl"), + `#!/usr/bin/env bash +printf 'sysctl %s\\n' "$*" >> "$COMMAND_LOG" +exit 0 `, ); writeExecutable( @@ -82,12 +89,34 @@ exit 0 const tproxyIndex = commands.findIndex((line) => line.includes("-p tcp -j TPROXY --on-port 7895"), ); + const ipForwardIndex = commands.findIndex((line) => + line.includes("sysctl -w net.ipv4.ip_forward=1"), + ); + const forwardAcceptIndex = commands.findIndex((line) => + line.includes( + "iptables -w -A VPN_PROXY_FWD_BYPASS -s 192.168.50.25/32 -j ACCEPT", + ), + ); + const forwardReturnIndex = commands.findIndex((line) => + line.includes( + "iptables -w -A VPN_PROXY_FWD_BYPASS -d 192.168.50.25/32 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT", + ), + ); + const natMasqueradeIndex = commands.findIndex((line) => + line.includes( + "iptables -w -t nat -A VPN_PROXY_NAT_BYPASS -s 192.168.50.25/32 -j MASQUERADE", + ), + ); assert.notEqual(sourceBypassIndex, -1); assert.notEqual(secondSourceBypassIndex, -1); assert.notEqual(sourceBypassJumpIndex, -1); assert.notEqual(directCacheIndex, -1); assert.notEqual(tproxyIndex, -1); + assert.notEqual(ipForwardIndex, -1); + assert.notEqual(forwardAcceptIndex, -1); + assert.notEqual(forwardReturnIndex, -1); + assert.notEqual(natMasqueradeIndex, -1); assert.ok(sourceBypassJumpIndex < directCacheIndex); assert.ok(sourceBypassJumpIndex < tproxyIndex); });