diff --git a/README.md b/README.md index 348cea7..3909ce4 100644 --- a/README.md +++ b/README.md @@ -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://: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 | diff --git a/entrypoint.sh b/entrypoint.sh index d88f831..6e0ed33 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 8d39e14..f1ff0b2 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -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 diff --git a/src/server/config.js b/src/server/config.js index d7438fd..466b9de 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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", diff --git a/src/server/devices.js b/src/server/devices.js index 40ddeeb..790b6fe 100644 --- a/src/server/devices.js +++ b/src/server/devices.js @@ -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; } diff --git a/src/server/index.js b/src/server/index.js index 0eeeccc..5ecdc95 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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)) { diff --git a/src/server/routeMatcher.js b/src/server/routeMatcher.js index 3e40b97..a528984 100644 --- a/src/server/routeMatcher.js +++ b/src/server/routeMatcher.js @@ -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 { diff --git a/src/server/tproxySourceBypass.js b/src/server/tproxySourceBypass.js new file mode 100644 index 0000000..3132830 --- /dev/null +++ b/src/server/tproxySourceBypass.js @@ -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 }; +} diff --git a/src/web/App.jsx b/src/web/App.jsx index 6092b32..16369ea 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -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 }); } diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx index 44163d9..dc19005 100644 --- a/src/web/components/RoutingPage.jsx +++ b/src/web/components/RoutingPage.jsx @@ -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 (