From 499d2d336704dc0d52f920ffe28fd92bc9a40aff Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 8 May 2026 21:42:45 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=83=D0=B6=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20SING=5FBOX=5FCONFIG=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy-gateway.sh | 1 - src/server/index.js | 110 ++++++++++++++++++++++++++------------ 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 6444ba0..e4ada4f 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -20,7 +20,6 @@ services: - .env environment: DATA_DIR: /var/lib/vpn-proxy - SING_BOX_CONFIG: /etc/sing-box/config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db volumes: - vpn-proxy-data:/var/lib/vpn-proxy diff --git a/src/server/index.js b/src/server/index.js index dc752a0..1ecef6c 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -31,35 +31,37 @@ const trafficBuffer = []; const trafficSubscribers = new Set(); // Паттерны для парсинга трафика из логов sing-box. -// sing-box пишет строки вида: -// [router] match[N][rule-name] => outbound/direct[direct] +// Форматы логов sing-box: +// [TCP] 192.168.1.1:PORT --> example.com:443 outbound/direct[direct] +// [UDP] 192.168.1.1:PORT --> 8.8.8.8:53 outbound/direct[direct] +// [router] match[N][rule-name] => outbound/direct[tag] // 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; -const TRAFFIC_DEST_RE = - /(?: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*\[([^\]]+)\]|\busing\s+rule\s*\[([^\]]+)\]/i; -// Строка роутера: [router] match[N][rule-name] => outbound/direct[tag] + +// Назначение после --> (основной формат sing-box) +const DEST_ARROW_RE = /-->\s*([\w.\-]+):(\d{1,5})/; +// Назначение в старом словесном стиле +const DEST_WORD_RE = + /(?:connection\s+to|dial(?:ing)?|connect(?:ing)?\s+to)\s+([\w.\-]+):(\d{1,5})/i; +// Тип аутбаунда: outbound/TYPE[tag] или outbound/TYPE +const OUTBOUND_RE = /outbound\/([a-z0-9_\-]+)/i; +// Строка роутера: [router] match[N][rule-name] => outbound/TYPE[tag] const ROUTER_MATCH_LINE_RE = - /\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound[/\\]([a-z0-9_\-]+)/i; + /\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound\/([a-z0-9_\-]+)/i; // Хранит имя последнего правила из [router] строки (для следующей строки с dest) let _pendingRuleName = null; let _pendingRuleAt = 0; -const RULE_CONTEXT_TTL_MS = 300; +const RULE_CONTEXT_TTL_MS = 500; function parseTrafficLine(line) { const clean = line.replace(/\x1b\[\d+m/g, "").trim(); - // Детектируем строку роутера — она содержит имя правила и outbound, но не host:port + // Детектируем строку роутера — содержит правило, но не dest const routerM = clean.match(ROUTER_MATCH_LINE_RE); if (routerM) { _pendingRuleName = routerM[1]; _pendingRuleAt = Date.now(); - return null; // не выводим отдельную запись в трафик + return null; } // Берём накопленное имя правила, если свежее @@ -70,37 +72,35 @@ function parseTrafficLine(line) { _pendingRuleName = null; _pendingRuleAt = 0; - const obMatch = clean.match(TRAFFIC_OUTBOUND_RE); - if (!obMatch) return null; - const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase(); - if (!outboundRaw) return null; + // Ищем outbound + const obM = clean.match(OUTBOUND_RE); + if (!obM) return null; + const outboundRaw = obM[1].toLowerCase(); - let category = "other"; - if (outboundRaw === "direct" || outboundRaw.startsWith("direct")) + // Пропускаем DNS-аутбаунды + if (outboundRaw === "dns-out" || outboundRaw === "dns") return null; + + let category; + 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-аутбаунды + else category = "vpn"; - const domainMatch = clean.match(TRAFFIC_DOMAIN_RE); - const destMatch = clean.match(TRAFFIC_DEST_RE); + // Ищем назначение: --> (основной формат), потом словесный + const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE); + if (!destM) return null; - 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] || ruleMatch?.[3] || inheritedRule || null; + const host = destM[1]; + const port = parseInt(destM[2], 10); return { ts: new Date().toISOString(), outbound: outboundRaw, category, - host: host || "", + host, port, - matchedRule, + matchedRule: inheritedRule, }; } @@ -126,10 +126,23 @@ function pushLog(level, line) { // Парсим трафик из info/debug строк if (level === "info" || level === "debug") { const traffic = parseTrafficLine(line); - if (traffic) pushTrafficEntry(traffic); + if (traffic) { + pushTrafficEntry(traffic); + } else if ( + (level === "info" || level === "debug") && + _debugUnparsed < 30 && + /\bTCP\b|\bUDP\b|\boutbound\b/i.test(line.replace(/\x1b\[\d+m/g, "")) + ) { + _debugUnparsed++; + process.stdout.write( + `[traffic:unmatched] ${line.replace(/\x1b\[\d+m/g, "").trim()}\n`, + ); + } } } +let _debugUnparsed = 0; + // Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки. // Формат: ESC[m LEVEL ESC[0m, где ESC = \x1b const SINGBOX_LEVEL_RE = @@ -1068,6 +1081,33 @@ if (existingPid && isPidAlive(existingPid)) { attachExistingSingbox(existingPid); } else { removeSingboxPid(); + + // Если конфиг отсутствует (например после передплоя), пробуем пересобрать из кэша + if (!fs.existsSync(settings.configPath)) { + const stateData = readJson(settings.statePath, {}); + const cached = readJson(settings.subscriptionCachePath, null); + if (stateData.selectedTag && cached?.config) { + try { + const customRules = readJson(settings.customRulesPath, []); + const generated = buildGatewayConfig( + { ...cached.config, customRules }, + stateData.selectedTag, + { bypassAll: Boolean(stateData.bypassMode) }, + ); + writeSingboxConfig(generated); + pushLog( + "info", + `Конфиг sing-box восстановлен из кэша (сервер: ${stateData.selectedTag})`, + ); + } catch (err) { + pushLog( + "error", + `Не удалось восстановить конфиг sing-box: ${err.message}`, + ); + } + } + } + await startSingbox().catch((error) => { console.warn(`[control] sing-box не запущен: ${error.message}`); pushLog("error", `sing-box не запущен при старте: ${error.message}`);