diff --git a/src/server/index.js b/src/server/index.js index d1a83b7..dc752a0 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -32,20 +32,44 @@ const trafficSubscribers = new Set(); // Паттерны для парсинга трафика из логов sing-box. // sing-box пишет строки вида: -// outbound/direct[tag]: dial tcp connection to host:port from ip:port -// [router] matched rule #0 [rule-name], outbound: vpn, domain: example.com +// [router] match[N][rule-name] => outbound/direct[direct] +// outbound/direct[tag]: dial tcp connection to host:port // [TCP] DIRECT host:port --> direct const TRAFFIC_OUTBOUND_RE = - /outbound[/\\]([a-z0-9_\-]+)|\boutbound:\s*([a-z0-9_\-]+)/i; + /outbound[/\\]([a-z0-9_\-]+)|\boutbound[:\s]+([a-z0-9_\-]+)/i; const TRAFFIC_DEST_RE = - /(?:to|dial|connection to|DIRECT|REJECT)\s+(?:tcp\s+|udp\s+)?(?:[^\s]*\s+to\s+)?([a-zA-Z0-9._\-]+|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})/i; + /(?:to|dial|connect(?:ion)?\s+to|accepted\s+(?:from\s+[^\s]+\s+to\s+)?)([a-zA-Z0-9._\-]+|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})(?!\d)/i; const TRAFFIC_DOMAIN_RE = /\bdomain:\s*([a-zA-Z0-9._\-]+)/i; const TRAFFIC_RULE_RE = - /matched\s+rule\s+#\d+\s*\[([^\]]+)\]|matched\s+\[([^\]]+)\]/i; + /matched?\s+rule\s+#?\d+\s*\[([^\]]+)\]|matched?\s*\[([^\]]+)\]|\busing\s+rule\s*\[([^\]]+)\]/i; +// Строка роутера: [router] match[N][rule-name] => outbound/direct[tag] +const ROUTER_MATCH_LINE_RE = + /\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound[/\\]([a-z0-9_\-]+)/i; + +// Хранит имя последнего правила из [router] строки (для следующей строки с dest) +let _pendingRuleName = null; +let _pendingRuleAt = 0; +const RULE_CONTEXT_TTL_MS = 300; function parseTrafficLine(line) { const clean = line.replace(/\x1b\[\d+m/g, "").trim(); + // Детектируем строку роутера — она содержит имя правила и outbound, но не host:port + const routerM = clean.match(ROUTER_MATCH_LINE_RE); + if (routerM) { + _pendingRuleName = routerM[1]; + _pendingRuleAt = Date.now(); + return null; // не выводим отдельную запись в трафик + } + + // Берём накопленное имя правила, если свежее + let inheritedRule = null; + if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) { + inheritedRule = _pendingRuleName; + } + _pendingRuleName = null; + _pendingRuleAt = 0; + const obMatch = clean.match(TRAFFIC_OUTBOUND_RE); if (!obMatch) return null; const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase(); @@ -67,7 +91,8 @@ function parseTrafficLine(line) { if (!host && !port) return null; const ruleMatch = clean.match(TRAFFIC_RULE_RE); - const matchedRule = ruleMatch?.[1] || ruleMatch?.[2] || null; + const matchedRule = + ruleMatch?.[1] || ruleMatch?.[2] || ruleMatch?.[3] || inheritedRule || null; return { ts: new Date().toISOString(), @@ -347,6 +372,7 @@ function publicState() { appliedHistory: state.appliedHistory || [], rulesUpdatedAt: state.rulesUpdatedAt || null, rulesAppliedAt: state.rulesAppliedAt || null, + bypassMode: Boolean(state.bypassMode), ...rest, }; } @@ -391,9 +417,11 @@ async function applySelectedServer(selectedTag) { } const customRules = readJson(settings.customRulesPath, []); + const stateForBypass = readJson(settings.statePath, {}); const generated = buildGatewayConfig( { ...cached.config, customRules }, selectedTag, + { bypassAll: Boolean(stateForBypass.bypassMode) }, ); writeSingboxConfig(generated); await startSingbox(); @@ -498,6 +526,35 @@ async function handleApi(req, res) { return sendJson(res, 200, { success: true }); } + if (req.method === "POST" && req.url === "/api/bypass") { + const body = await readBody(req); + const enabled = Boolean(body.enabled); + const prevState = readJson(settings.statePath, {}); + writeJson(settings.statePath, { ...prevState, bypassMode: enabled }); + + // Перегенерируем и применяем конфиг, если sing-box запущен + if (singboxProcess && prevState.selectedTag) { + const cached = readJson(settings.subscriptionCachePath, null); + if (cached?.config) { + const customRules = readJson(settings.customRulesPath, []); + const generated = buildGatewayConfig( + { ...cached.config, customRules }, + prevState.selectedTag, + { bypassAll: enabled }, + ); + writeSingboxConfig(generated); + await startSingbox(); + pushLog( + "info", + enabled + ? "Режим обхода включён — весь трафик идёт напрямую" + : "Режим обхода отключён — правила маршрутизации восстановлены", + ); + } + } + return sendJson(res, 200, { success: true, bypassMode: enabled }); + } + if (req.method === "GET" && req.url === "/api/rules") { return sendJson(res, 200, { success: true, diff --git a/src/server/singbox.js b/src/server/singbox.js index 5d1d3bf..c27c957 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -166,7 +166,11 @@ function routeRules(customRules, vpnTag) { return rules; } -export function buildGatewayConfig(subscriptionConfig, selectedTag) { +export function buildGatewayConfig( + subscriptionConfig, + selectedTag, + { bypassAll = false } = {}, +) { const selectedOutbound = findOutbound(subscriptionConfig, selectedTag); if (!selectedOutbound) { throw new Error(`Outbound не найден: ${selectedTag}`); @@ -218,9 +222,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { { type: "block", tag: "block" }, ], route: { - rule_set: ruleSets(customRuleSets), - rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), - final: vpnOutbound.tag, + rule_set: bypassAll ? [] : ruleSets(customRuleSets), + rules: bypassAll + ? [{ ip_is_private: true, outbound: "direct" }] + : routeRules(subscriptionConfig.customRules, vpnOutbound.tag), + final: bypassAll ? "direct" : vpnOutbound.tag, auto_detect_interface: true, }, }; diff --git a/src/web/App.jsx b/src/web/App.jsx index 60f3010..90cf86d 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -175,6 +175,17 @@ function App() { }); } + async function toggleBypass() { + const next = !state?.bypassMode; + return withBusy( + next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён', + async () => { + await api.bypass(next); + await loadState(); + }, + ); + } + // === Rules CRUD === function emptyRule() { return { @@ -309,6 +320,7 @@ function App() { onStop={stopSingbox} onShowConfig={() => setConfigOpen(true)} onNav={navigate} + onBypassToggle={toggleBypass} /> )} {page === 'servers' && ( diff --git a/src/web/api.js b/src/web/api.js index a91d895..c951310 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -72,6 +72,12 @@ export const api = { pingAll: () => request("/api/servers/ping-all", { method: "POST" }), }, + bypass: (enabled) => + request("/api/bypass", { + method: "POST", + body: JSON.stringify({ enabled }), + }), + route: { check: ({ host, ip, port, network }) => request("/api/route/check", { diff --git a/src/web/components/LogsPage.jsx b/src/web/components/LogsPage.jsx index e1c8671..ec321c8 100644 --- a/src/web/components/LogsPage.jsx +++ b/src/web/components/LogsPage.jsx @@ -33,23 +33,21 @@ const CATEGORY_BADGE = { other: { cls: '', label: 'other' }, }; -const TRAFFIC_GROUP_WINDOW_MS = 60_000; - function groupTraffic(list) { - const out = []; + const map = new Map(); for (const e of list) { const key = `${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`; const ts = new Date(e.ts).getTime(); - const last = out[out.length - 1]; - if (last && last._key === key && ts - last._lastTs < TRAFFIC_GROUP_WINDOW_MS) { - last.count += 1; - last._lastTs = ts; - last.lastTs = e.ts; + if (map.has(key)) { + const g = map.get(key); + g.count++; + g._lastTs = ts; + g.lastTs = e.ts; } else { - out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts }); + map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts }); } } - return out; + return Array.from(map.values()).sort((a, b) => b._lastTs - a._lastTs); } function TrafficTab() { diff --git a/src/web/components/OverviewPage.jsx b/src/web/components/OverviewPage.jsx index 070c5f1..2821cbc 100644 --- a/src/web/components/OverviewPage.jsx +++ b/src/web/components/OverviewPage.jsx @@ -55,7 +55,7 @@ function StatusHero({ state, status }) { ); } -function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav }) { +function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) { return (
@@ -74,6 +74,14 @@ function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav }) { +
); @@ -145,12 +153,21 @@ function RoutingSummary({ state, onNav }) { ); } -export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav }) { +export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) { return (
+ {state?.bypassMode && ( +
+ ⚠ Режим обхода правил активен + — весь трафик идёт напрямую, VPN-правила не применяются. + +
+ )}
- +