diff --git a/Dockerfile b/Dockerfile index 382fd97..279ed83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG SINGBOX_VERSION=1.12.13 COPY dist /app/dist RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \ + && apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \ && rm -rf /var/lib/apt/lists/* RUN set -eux; \ diff --git a/entrypoint.sh b/entrypoint.sh index d212439..ffed081 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,6 +6,10 @@ TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" BYPASS_CIDRS="${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}" +# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache) +DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}" +# TTL записи в ipset (секунды). По умолчанию 1 час. +DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}" log() { printf '[gateway-entrypoint] %s\n' "$*" @@ -22,6 +26,15 @@ cleanup_tproxy() { ipt -t mangle -X "$TPROXY_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 сам истечёт +} + +setup_direct_bypass_set() { + log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)" + # Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи) + ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true + # Экспортируем имя для использования в Node.js через env + export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL } setup_tproxy() { @@ -36,6 +49,9 @@ setup_tproxy() { 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 + # Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box + ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN + for cidr in $BYPASS_CIDRS; do ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN done @@ -45,6 +61,7 @@ setup_tproxy() { ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN" } +setup_direct_bypass_set setup_tproxy node /app/src/server/index.js & diff --git a/src/server/index.js b/src/server/index.js index 2449aa6..10b5045 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -21,6 +21,68 @@ fs.mkdirSync(settings.dataDir, { recursive: true }); const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid"); +// ─── Direct bypass cache (ipset) ──────────────────────────────────────────── +const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass"; +const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600"; +const IPSET_AVAILABLE = (() => { + try { + spawnSync("ipset", ["version"], { timeout: 1000 }); + return true; + } catch { + return false; + } +})(); +const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/; + +// Локальный счётчик добавленных IP (RAM-only, сбрасывается при перезапуске) +let directBypassCount = 0; + +function addToDirectBypass(ip) { + if (!IPSET_AVAILABLE || !IP_RE.test(ip)) return; + try { + spawnSync( + "ipset", + ["add", DIRECT_BYPASS_SET, ip, "timeout", DIRECT_BYPASS_TTL, "-exist"], + { + timeout: 500, + }, + ); + directBypassCount++; + } catch {} +} + +function flushDirectBypass() { + directBypassCount = 0; + if (!IPSET_AVAILABLE) return; + try { + spawnSync("ipset", ["flush", DIRECT_BYPASS_SET], { timeout: 1000 }); + } catch {} +} + +function listDirectBypass() { + if (!IPSET_AVAILABLE) return []; + try { + const result = spawnSync( + "ipset", + ["list", DIRECT_BYPASS_SET, "-output", "plain"], + { + encoding: "utf8", + timeout: 2000, + }, + ); + const lines = (result.stdout || "").split("\n"); + // После строки "Members:" идут IP-адреса + const membersIdx = lines.findIndex((l) => l.trim() === "Members:"); + if (membersIdx === -1) return []; + return lines + .slice(membersIdx + 1) + .map((l) => l.trim().split(" ")[0]) + .filter((l) => IP_RE.test(l)); + } catch { + return []; + } +} + let singboxProcess = null; let singboxStartedAt = null; const LOG_BUFFER_SIZE = 500; @@ -129,6 +191,10 @@ function pushLog(level, line) { const traffic = parseTrafficLine(line); if (traffic) { pushTrafficEntry(traffic); + // Если direct и назначение — IP, добавляем в bypass-кэш + if (traffic.category === "direct" && IP_RE.test(traffic.host)) { + addToDirectBypass(traffic.host); + } } else if ( (level === "info" || level === "debug") && _debugUnparsed < 30 && @@ -387,6 +453,8 @@ function publicState() { rulesUpdatedAt: state.rulesUpdatedAt || null, rulesAppliedAt: state.rulesAppliedAt || null, bypassMode: Boolean(state.bypassMode), + directBypassCount, + directBypassAvailable: IPSET_AVAILABLE, ...rest, }; } @@ -540,6 +608,21 @@ async function handleApi(req, res) { return sendJson(res, 200, { success: true }); } + if (req.method === "GET" && req.url === "/api/direct-cache") { + const members = listDirectBypass(); + return sendJson(res, 200, { + success: true, + count: members.length, + available: IPSET_AVAILABLE, + members, + }); + } + + if (req.method === "DELETE" && req.url === "/api/direct-cache") { + flushDirectBypass(); + return sendJson(res, 200, { success: true }); + } + if (req.method === "POST" && req.url === "/api/bypass") { const body = await readBody(req); const enabled = Boolean(body.enabled); diff --git a/src/web/App.jsx b/src/web/App.jsx index 90cf86d..b0f232b 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -186,6 +186,13 @@ function App() { ); } + async function flushDirectCache() { + return withBusy('Bypass-кэш сброшен', async () => { + await api.directCache.flush(); + await loadState(); + }); + } + // === Rules CRUD === function emptyRule() { return { @@ -321,6 +328,7 @@ function App() { onShowConfig={() => setConfigOpen(true)} onNav={navigate} onBypassToggle={toggleBypass} + onFlushDirectCache={flushDirectCache} /> )} {page === 'servers' && ( diff --git a/src/web/api.js b/src/web/api.js index c951310..aa691e0 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -78,6 +78,11 @@ export const api = { body: JSON.stringify({ enabled }), }), + directCache: { + get: () => request("/api/direct-cache"), + flush: () => request("/api/direct-cache", { method: "DELETE" }), + }, + route: { check: ({ host, ip, port, network }) => request("/api/route/check", { diff --git a/src/web/components/OverviewPage.jsx b/src/web/components/OverviewPage.jsx index 2821cbc..ff9aa3d 100644 --- a/src/web/components/OverviewPage.jsx +++ b/src/web/components/OverviewPage.jsx @@ -132,9 +132,11 @@ function RecentEvents({ onNav }) { ); } -function RoutingSummary({ state, onNav }) { +function RoutingSummary({ state, onNav, onFlushDirectCache }) { const rules = state?.customRules || []; const enabled = rules.filter((r) => r.enabled).length; + const cacheCount = state?.directBypassCount || 0; + const cacheAvailable = state?.directBypassAvailable; return (