feat: добавлена возможность обхода правил для трафика
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
- Реализована функция для включения и отключения обхода правил. - Обновлены компоненты интерфейса для управления режимом обхода. - Добавлена обработка состояния обхода в API. Refs: None
This commit is contained in:
@@ -32,20 +32,44 @@ const trafficSubscribers = new Set();
|
|||||||
|
|
||||||
// Паттерны для парсинга трафика из логов sing-box.
|
// Паттерны для парсинга трафика из логов sing-box.
|
||||||
// sing-box пишет строки вида:
|
// sing-box пишет строки вида:
|
||||||
// outbound/direct[tag]: dial tcp connection to host:port from ip:port
|
// [router] match[N][rule-name] => outbound/direct[direct]
|
||||||
// [router] matched rule #0 [rule-name], outbound: vpn, domain: example.com
|
// outbound/direct[tag]: dial tcp connection to host:port
|
||||||
// [TCP] DIRECT host:port --> direct
|
// [TCP] DIRECT host:port --> direct
|
||||||
const TRAFFIC_OUTBOUND_RE =
|
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 =
|
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_DOMAIN_RE = /\bdomain:\s*([a-zA-Z0-9._\-]+)/i;
|
||||||
const TRAFFIC_RULE_RE =
|
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) {
|
function parseTrafficLine(line) {
|
||||||
const clean = line.replace(/\x1b\[\d+m/g, "").trim();
|
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);
|
const obMatch = clean.match(TRAFFIC_OUTBOUND_RE);
|
||||||
if (!obMatch) return null;
|
if (!obMatch) return null;
|
||||||
const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase();
|
const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase();
|
||||||
@@ -67,7 +91,8 @@ function parseTrafficLine(line) {
|
|||||||
if (!host && !port) return null;
|
if (!host && !port) return null;
|
||||||
|
|
||||||
const ruleMatch = clean.match(TRAFFIC_RULE_RE);
|
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 {
|
return {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
@@ -347,6 +372,7 @@ function publicState() {
|
|||||||
appliedHistory: state.appliedHistory || [],
|
appliedHistory: state.appliedHistory || [],
|
||||||
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
||||||
rulesAppliedAt: state.rulesAppliedAt || null,
|
rulesAppliedAt: state.rulesAppliedAt || null,
|
||||||
|
bypassMode: Boolean(state.bypassMode),
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -391,9 +417,11 @@ async function applySelectedServer(selectedTag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
const customRules = readJson(settings.customRulesPath, []);
|
||||||
|
const stateForBypass = readJson(settings.statePath, {});
|
||||||
const generated = buildGatewayConfig(
|
const generated = buildGatewayConfig(
|
||||||
{ ...cached.config, customRules },
|
{ ...cached.config, customRules },
|
||||||
selectedTag,
|
selectedTag,
|
||||||
|
{ bypassAll: Boolean(stateForBypass.bypassMode) },
|
||||||
);
|
);
|
||||||
writeSingboxConfig(generated);
|
writeSingboxConfig(generated);
|
||||||
await startSingbox();
|
await startSingbox();
|
||||||
@@ -498,6 +526,35 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true });
|
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") {
|
if (req.method === "GET" && req.url === "/api/rules") {
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ function routeRules(customRules, vpnTag) {
|
|||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
export function buildGatewayConfig(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedTag,
|
||||||
|
{ bypassAll = false } = {},
|
||||||
|
) {
|
||||||
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
||||||
if (!selectedOutbound) {
|
if (!selectedOutbound) {
|
||||||
throw new Error(`Outbound не найден: ${selectedTag}`);
|
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
@@ -218,9 +222,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|||||||
{ type: "block", tag: "block" },
|
{ type: "block", tag: "block" },
|
||||||
],
|
],
|
||||||
route: {
|
route: {
|
||||||
rule_set: ruleSets(customRuleSets),
|
rule_set: bypassAll ? [] : ruleSets(customRuleSets),
|
||||||
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
rules: bypassAll
|
||||||
final: vpnOutbound.tag,
|
? [{ ip_is_private: true, outbound: "direct" }]
|
||||||
|
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
||||||
|
final: bypassAll ? "direct" : vpnOutbound.tag,
|
||||||
auto_detect_interface: true,
|
auto_detect_interface: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ===
|
// === Rules CRUD ===
|
||||||
function emptyRule() {
|
function emptyRule() {
|
||||||
return {
|
return {
|
||||||
@@ -309,6 +320,7 @@ function App() {
|
|||||||
onStop={stopSingbox}
|
onStop={stopSingbox}
|
||||||
onShowConfig={() => setConfigOpen(true)}
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
onNav={navigate}
|
onNav={navigate}
|
||||||
|
onBypassToggle={toggleBypass}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === 'servers' && (
|
{page === 'servers' && (
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export const api = {
|
|||||||
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
|
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
bypass: (enabled) =>
|
||||||
|
request("/api/bypass", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}),
|
||||||
|
|
||||||
route: {
|
route: {
|
||||||
check: ({ host, ip, port, network }) =>
|
check: ({ host, ip, port, network }) =>
|
||||||
request("/api/route/check", {
|
request("/api/route/check", {
|
||||||
|
|||||||
@@ -33,23 +33,21 @@ const CATEGORY_BADGE = {
|
|||||||
other: { cls: '', label: 'other' },
|
other: { cls: '', label: 'other' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRAFFIC_GROUP_WINDOW_MS = 60_000;
|
|
||||||
|
|
||||||
function groupTraffic(list) {
|
function groupTraffic(list) {
|
||||||
const out = [];
|
const map = new Map();
|
||||||
for (const e of list) {
|
for (const e of list) {
|
||||||
const key = `${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
const key = `${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
||||||
const ts = new Date(e.ts).getTime();
|
const ts = new Date(e.ts).getTime();
|
||||||
const last = out[out.length - 1];
|
if (map.has(key)) {
|
||||||
if (last && last._key === key && ts - last._lastTs < TRAFFIC_GROUP_WINDOW_MS) {
|
const g = map.get(key);
|
||||||
last.count += 1;
|
g.count++;
|
||||||
last._lastTs = ts;
|
g._lastTs = ts;
|
||||||
last.lastTs = e.ts;
|
g.lastTs = e.ts;
|
||||||
} else {
|
} 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() {
|
function TrafficTab() {
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -74,6 +74,14 @@ function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav }) {
|
|||||||
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
|
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
|
||||||
⌘ Показать config
|
⌘ Показать config
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
|
||||||
|
disabled={busy || !state?.singboxRunning}
|
||||||
|
onClick={onBypassToggle}
|
||||||
|
title="Весь трафик напрямую — для диагностики"
|
||||||
|
>
|
||||||
|
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="section-stack">
|
<div className="section-stack">
|
||||||
|
{state?.bypassMode && (
|
||||||
|
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<strong>⚠ Режим обхода правил активен</strong>
|
||||||
|
<span className="muted">— весь трафик идёт напрямую, VPN-правила не применяются.</span>
|
||||||
|
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<StatusHero state={state} status={status} />
|
<StatusHero state={state} status={status} />
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} />
|
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
||||||
<RoutingSummary state={state} onNav={onNav} />
|
<RoutingSummary state={state} onNav={onNav} />
|
||||||
</div>
|
</div>
|
||||||
<RecentEvents onNav={onNav} />
|
<RecentEvents onNav={onNav} />
|
||||||
|
|||||||
Reference in New Issue
Block a user