feat: добавлена обработка трафика и интерфейс для его отображения

Refs: None
This commit is contained in:
2026-05-08 21:02:18 +03:00
parent bb7250e4ac
commit 49be90a82c
3 changed files with 514 additions and 66 deletions

View File

@@ -26,6 +26,62 @@ const LOG_BUFFER_SIZE = 500;
const logBuffer = [];
const logSubscribers = new Set();
const TRAFFIC_BUFFER_SIZE = 500;
const trafficBuffer = [];
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
// [TCP] DIRECT host:port --> direct
const TRAFFIC_OUTBOUND_RE = /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;
const TRAFFIC_DOMAIN_RE = /\bdomain:\s*([a-zA-Z0-9._\-]+)/i;
const TRAFFIC_RULE_RE = /matched\s+rule\s+#\d+\s*\[([^\]]+)\]|matched\s+\[([^\]]+)\]/i;
function parseTrafficLine(line) {
const clean = line.replace(/\x1b\[\d+m/g, "").trim();
const obMatch = clean.match(TRAFFIC_OUTBOUND_RE);
if (!obMatch) return null;
const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase();
if (!outboundRaw) return null;
let category = "other";
if (outboundRaw === "direct" || outboundRaw.startsWith("direct")) category = "direct";
else if (outboundRaw === "block" || outboundRaw === "reject") category = "block";
else if (outboundRaw !== "dns-out" && outboundRaw !== "dns") category = "vpn";
else return null; // пропускаем DNS-аутбаунды
const domainMatch = clean.match(TRAFFIC_DOMAIN_RE);
const destMatch = clean.match(TRAFFIC_DEST_RE);
const host = domainMatch?.[1] || destMatch?.[1] || null;
const port = destMatch?.[2] ? parseInt(destMatch[2], 10) : null;
if (!host && !port) return null;
const ruleMatch = clean.match(TRAFFIC_RULE_RE);
const matchedRule = ruleMatch?.[1] || ruleMatch?.[2] || null;
return {
ts: new Date().toISOString(),
outbound: outboundRaw,
category,
host: host || "",
port,
matchedRule,
};
}
function pushTrafficEntry(entry) {
trafficBuffer.push(entry);
if (trafficBuffer.length > TRAFFIC_BUFFER_SIZE) trafficBuffer.shift();
for (const sub of trafficSubscribers) {
try { sub(entry); } catch {}
}
}
function pushLog(level, line) {
const entry = { ts: new Date().toISOString(), level, line };
logBuffer.push(entry);
@@ -35,6 +91,11 @@ function pushLog(level, line) {
subscriber(entry);
} catch {}
}
// Парсим трафик из info/debug строк
if (level === "info" || level === "debug") {
const traffic = parseTrafficLine(line);
if (traffic) pushTrafficEntry(traffic);
}
}
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
@@ -401,6 +462,28 @@ async function handleApi(req, res) {
return handleLogsStream(req, res);
}
if (req.method === "GET" && req.url === "/api/traffic/stream") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
});
for (const entry of trafficBuffer.slice(-200)) {
res.write(`data: ${JSON.stringify(entry)}\n\n`);
}
const sub = (entry) => res.write(`data: ${JSON.stringify(entry)}\n\n`);
trafficSubscribers.add(sub);
const keepalive = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 15000);
req.on("close", () => { clearInterval(keepalive); trafficSubscribers.delete(sub); });
return;
}
if (req.method === "DELETE" && req.url === "/api/traffic") {
trafficBuffer.splice(0);
return sendJson(res, 200, { success: true });
}
if (req.method === "GET" && req.url === "/api/rules") {
return sendJson(res, 200, {
success: true,