import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import { settings } from "./config.js"; import { fetchSubscription } from "./subscription.js"; import os from "node:os"; import { buildGatewayConfig, writeSingboxConfig, readSingboxConfig, removeSingboxConfig, } from "./singbox.js"; import { legacyDeviceRulesFromProfiles, readDeviceProfiles, writeDeviceProfiles, } from "./devices.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; const APPLY_HISTORY_LIMIT = 10; const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i; const FALLBACK_RULE_SET_CATALOG = { geosite: [ "geosite-category-ru", "geosite-category-ai-!cn", "geosite-geolocation-!cn", "geosite-google", "geosite-youtube", "geosite-telegram", "geosite-openai", "geosite-apple", "geosite-github", "geosite-steam", "geosite-discord", "geosite-netflix", "geosite-cloudflare", "geosite-category-ads-all", ], geoip: [ "geoip-ru", "geoip-cloudflare", "geoip-telegram", "geoip-google", "geoip-netflix", "geoip-private", ], }; 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 DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true"; const IPSET_AVAILABLE = (() => { try { const result = spawnSync("ipset", ["version"], { timeout: 1000 }); return !result.error && result.status === 0; } catch { return false; } })(); const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/; // Локальный счётчик добавленных IP (RAM-only, сбрасывается при перезапуске) let directBypassCount = 0; function addToDirectBypass(ip) { if (!DIRECT_BYPASS_CACHE || !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 (!DIRECT_BYPASS_CACHE || !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; const logBuffer = []; const logSubscribers = new Set(); const TRAFFIC_BUFFER_SIZE = 500; const trafficBuffer = []; const trafficSubscribers = new Set(); // Паттерны для парсинга трафика из логов sing-box. // Форматы логов 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 // Назначение после --> (старый формат 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; // ID соединения: [CONN_ID Nms] const CONN_ID_RE = /\[(\d{5,12})\s+\d+ms\]/; // Входящее соединение от устройства: inbound [packet] connection from IP:PORT const INBOUND_FROM_RE = /inbound(?:\s+packet)?\s+connection\s+from\s+([\d.]+):\d+/i; // Source IP из --> формата: IP:PORT --> const SOURCE_ARROW_RE = /\b([\d.]+):\d+\s+-->/; // Карта source IP по ID соединения const CONN_TTL_MS = 10_000; const connSourceMap = new Map(); setInterval(() => { const now = Date.now(); for (const [id, v] of connSourceMap) { if (now - v.at > CONN_TTL_MS) connSourceMap.delete(id); } }, 30_000); // Хранит имя последнего правила из [router] строки (для следующей строки с dest) let _pendingRuleName = null; let _pendingRuleAt = 0; const RULE_CONTEXT_TTL_MS = 500; function parseTrafficLine(line) { // Расширенная очистка ANSI (включая многопараметрические: \x1b[38;5;Nm) const clean = line.replace(/\x1b\[[0-9;]*m/g, "").trim(); // Детектируем строку роутера — содержит правило, но не dest const routerM = clean.match(ROUTER_MATCH_LINE_RE); if (routerM) { _pendingRuleName = routerM[1]; _pendingRuleAt = Date.now(); return null; } // Извлекаем ID соединения для корреляции const connM = clean.match(CONN_ID_RE); const connId = connM ? connM[1] : null; // Строка "inbound connection from IP:PORT" — сохраняем source IP и выходим const inboundFromM = clean.match(INBOUND_FROM_RE); if (inboundFromM) { if (connId) connSourceMap.set(connId, { sourceIp: inboundFromM[1], at: Date.now() }); return null; } // Берём накопленное имя правила, если свежее let inheritedRule = null; if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) { inheritedRule = _pendingRuleName; } _pendingRuleName = null; _pendingRuleAt = 0; // Ищем outbound const obM = clean.match(OUTBOUND_RE); if (!obM) return null; const outboundRaw = obM[1].toLowerCase(); // Пропускаем 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 category = "vpn"; // Ищем назначение: --> (старый формат), потом словесный (inbound/outbound connection to) const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE); if (!destM) return null; const host = destM[1]; const port = parseInt(destM[2], 10); // Source IP: из корреляционной карты (новый формат) или из --> (старый формат) let sourceIp = null; if (connId) { const stored = connSourceMap.get(connId); if (stored && Date.now() - stored.at < CONN_TTL_MS) sourceIp = stored.sourceIp; } if (!sourceIp) { const srcM = clean.match(SOURCE_ARROW_RE); if (srcM) sourceIp = srcM[1]; } return { ts: new Date().toISOString(), outbound: outboundRaw, category, host, port, sourceIp, matchedRule: inheritedRule, }; } 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); if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift(); for (const subscriber of logSubscribers) { try { subscriber(entry); } catch {} } // Парсим трафик из info/debug строк if (level === "info" || level === "debug") { 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 && /\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 = /\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i; function parseSingboxLevel(line, fallback) { const m = line.match(SINGBOX_LEVEL_RE); if (!m) return fallback; const l = m[1].toLowerCase(); if (l === "warn") return "warning"; if (l === "fatal") return "error"; return l; // trace, debug, info, error } // ─── PID helpers ──────────────────────────────────────────────────────────── function saveSingboxPid(pid) { try { fs.writeFileSync(SINGBOX_PID_FILE, String(pid), "utf8"); } catch {} } function readSingboxPid() { try { const pid = parseInt(fs.readFileSync(SINGBOX_PID_FILE, "utf8").trim(), 10); return Number.isFinite(pid) && pid > 0 ? pid : null; } catch { return null; } } function removeSingboxPid() { try { fs.unlinkSync(SINGBOX_PID_FILE); } catch {} } function isPidAlive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch { return false; } } /** * Подхватывает уже запущенный sing-box по PID — без перезапуска. * Логи недоступны (процесс запущен раньше), но kill/stop работает. */ function attachExistingSingbox(pid) { const stateData = readJson(settings.statePath, {}); singboxStartedAt = stateData.appliedAt || new Date().toISOString(); let exitCb = null; singboxProcess = { pid, kill: (sig = "SIGTERM") => { try { process.kill(pid, sig); } catch {} }, once: (event, cb) => { if (event === "exit") exitCb = cb; }, }; // Периодически проверяем, что процесс ещё жив const watcher = setInterval(() => { if (!isPidAlive(pid)) { clearInterval(watcher); if (singboxProcess?.pid === pid) { singboxProcess = null; singboxStartedAt = null; removeSingboxPid(); pushLog("warning", `sing-box (pid=${pid}) завершился`); } if (exitCb) { exitCb(null, null); exitCb = null; } } }, 2000); pushLog("info", `sing-box подхвачен при старте (pid=${pid})`); } function captureStream(stream, fallbackLevel) { let remainder = ""; stream.setEncoding("utf8"); stream.on("data", (chunk) => { const data = remainder + chunk; const lines = data.split(/\r?\n/); remainder = lines.pop() || ""; for (const line of lines) { if (!line) continue; const level = parseSingboxLevel(line, fallbackLevel); process.stdout.write(`[sing-box:${level}] ${line}\n`); pushLog(level, line); } }); stream.on("end", () => { if (remainder) { const level = parseSingboxLevel(remainder, fallbackLevel); process.stdout.write(`[sing-box:${level}] ${remainder}\n`); pushLog(level, remainder); } remainder = ""; }); } function readJson(filePath, fallback) { try { if (!fs.existsSync(filePath)) return fallback; return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return fallback; } } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8"); } function maskSubscriptionUrl(url) { if (!url) return ""; try { const parsed = new URL(url); return `${parsed.hostname}/...`; } catch { return url.length > 32 ? `${url.slice(0, 32)}...` : url; } } function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload, null, 2); res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(body), }); res.end(body); } function normalizeRuleSetUrl(url) { const value = String(url || "").trim(); if (!value) return []; const urls = [value]; const jsdelivrMatch = value.match( /^https:\/\/cdn\.jsdelivr\.net\/gh\/([^/]+)\/([^@/]+)@([^/]+)\/(.+)$/i, ); if (jsdelivrMatch) { const [, owner, repo, ref, filePath] = jsdelivrMatch; urls.push( `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`, ); } const rawMatch = value.match( /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/i, ); if (rawMatch) { const [, owner, repo, ref, filePath] = rawMatch; urls.push(`https://cdn.jsdelivr.net/gh/${owner}/${repo}@${ref}/${filePath}`); } return Array.from(new Set(urls)); } function downloadFile(urlOrUrls, outputPath) { return new Promise((resolve, reject) => { const candidates = Array.isArray(urlOrUrls) ? Array.from(new Set(urlOrUrls.flatMap((url) => normalizeRuleSetUrl(url)))) : normalizeRuleSetUrl(urlOrUrls); let index = 0; let lastError = ""; function tryNext() { const url = candidates[index]; if (!url) { reject(new Error(lastError || "Не удалось скачать файл")); return; } let stderr = ""; const dl = spawn("curl", [ "-fsSL", "--retry", "2", "--retry-delay", "1", "--connect-timeout", "10", "--max-time", "45", "-A", "vpn-proxy-app", url, "-o", outputPath, ]); dl.stderr.on("data", (d) => { stderr += d; }); dl.on("error", (err) => { lastError = err.message; index += 1; tryNext(); }); dl.on("close", (code) => { if (code === 0) { resolve(url); return; } lastError = `curl ${url} завершился с кодом ${code}: ${stderr}`; index += 1; tryNext(); }); } tryNext(); }); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(new Error("Невалидный JSON в теле запроса")); } }); req.on("error", reject); }); } function checkSingboxConfig() { const result = spawnSync("sing-box", ["check", "-c", settings.configPath], { encoding: "utf8", }); if (result.status !== 0) { throw new Error( (result.stderr || result.stdout || "sing-box check failed").trim(), ); } } function stopSingbox() { return new Promise((resolve) => { if (!singboxProcess) { singboxStartedAt = null; return resolve(); } const current = singboxProcess; singboxProcess = null; singboxStartedAt = null; const timeout = setTimeout(() => { current.kill("SIGKILL"); resolve(); }, 4000); current.once("exit", () => { clearTimeout(timeout); resolve(); }); current.kill("SIGTERM"); }); } async function startSingbox() { if (!fs.existsSync(settings.configPath)) return false; checkSingboxConfig(); await stopSingbox(); singboxProcess = spawn("sing-box", ["run", "-c", settings.configPath], { stdio: ["ignore", "pipe", "pipe"], }); singboxStartedAt = new Date().toISOString(); saveSingboxPid(singboxProcess.pid); pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`); captureStream(singboxProcess.stdout, "info"); captureStream(singboxProcess.stderr, "error"); singboxProcess.once("exit", (code, signal) => { pushLog("info", `sing-box завершён: code=${code} signal=${signal}`); singboxProcess = null; singboxStartedAt = null; removeSingboxPid(); }); return true; } function publicState() { const state = readJson(settings.statePath, {}); const customRules = readJson(settings.customRulesPath, []); const deviceProfiles = readDeviceProfiles(); const { subscriptionUrl, ...rest } = state; return { mode: settings.appMode, port: settings.port, proxyPort: settings.proxyPort, proxyBindIp: settings.bindIp, tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null, routingRuDirect: settings.routingRuDirect, configExists: fs.existsSync(settings.configPath), singboxRunning: Boolean(singboxProcess), singboxStartedAt, subscriptionHost: maskSubscriptionUrl(subscriptionUrl), hasSubscription: Boolean(subscriptionUrl), customRules, devicesConfig: deviceProfiles, devices: deviceProfiles.devices, deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles), appliedHistory: state.appliedHistory || [], rulesUpdatedAt: state.rulesUpdatedAt || null, devicesUpdatedAt: state.devicesUpdatedAt || null, rulesAppliedAt: state.rulesAppliedAt || null, bypassMode: Boolean(state.bypassMode), directBypassCount, directBypassEnabled: DIRECT_BYPASS_CACHE, directBypassAvailable: IPSET_AVAILABLE, ...rest, }; } function normalizeList(value) { if (Array.isArray(value)) { return value.map((item) => String(item || "").trim()).filter(Boolean); } return String(value || "") .split(/\r?\n|,/) .map((item) => item.trim()) .filter(Boolean); } function normalizeCustomRules(input) { const rules = Array.isArray(input) ? input : []; return rules.map((rule, index) => ({ id: String(rule.id || `rule-${Date.now()}-${index}`), name: String(rule.name || `Правило ${index + 1}`).trim(), enabled: rule.enabled !== false, outbound: ["direct", "vpn", "block"].includes(rule.outbound) ? rule.outbound : "direct", domains: normalizeList(rule.domains), domainSuffixes: normalizeList(rule.domainSuffixes), domainKeywords: normalizeList(rule.domainKeywords), ipCidrs: normalizeList(rule.ipCidrs), ports: normalizeList(rule.ports), networks: normalizeList(rule.networks).filter((network) => ["tcp", "udp"].includes(network), ), ruleSets: normalizeList(rule.ruleSets).filter((tag) => RULE_SET_TAG_RE.test(tag), ), })); } function normalizeDeviceRules(input) { const rules = Array.isArray(input) ? input : []; return rules.map((r, index) => ({ id: String(r.id || `dev-${Date.now()}-${index}`), name: String(r.name || `Устройство ${index + 1}`).trim(), enabled: r.enabled !== false, sourceIps: normalizeList(r.sourceIps).filter((ip) => /^[\.\d:/]+$/.test(ip), ), outbound: ["direct", "vpn", "block"].includes(r.outbound) ? r.outbound : "direct", })); } async function applySelectedServer(selectedTag) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) { throw new Error("Сначала загрузите подписку"); } 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(); const prevState = readJson(settings.statePath, {}); const now = new Date().toISOString(); const previousTag = prevState.selectedTag && prevState.selectedTag !== selectedTag ? prevState.selectedTag : prevState.previousTag || null; const history = Array.isArray(prevState.appliedHistory) ? prevState.appliedHistory : []; const nextHistory = [ { tag: selectedTag, at: now }, ...history.filter((h) => h.tag !== selectedTag), ].slice(0, APPLY_HISTORY_LIMIT); writeJson(settings.statePath, { ...prevState, selectedTag, previousTag, appliedAt: now, rulesAppliedAt: now, appliedHistory: nextHistory, }); } function handleLogsStream(req, res) { 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 logBuffer.slice(-200)) { res.write(`data: ${JSON.stringify(entry)}\n\n`); } const subscriber = (entry) => { res.write(`data: ${JSON.stringify(entry)}\n\n`); }; logSubscribers.add(subscriber); const keepalive = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 15000); req.on("close", () => { clearInterval(keepalive); logSubscribers.delete(subscriber); }); } async function handleApi(req, res) { if (req.method === "GET" && req.url === "/api/state") { return sendJson(res, 200, publicState()); } if (req.method === "GET" && req.url === "/api/config") { const config = readSingboxConfig(); return sendJson(res, 200, { success: true, config }); } if (req.method === "GET" && req.url === "/api/logs") { return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) }); } if (req.method === "GET" && req.url === "/api/logs/stream") { 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/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); 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, rules: readJson(settings.customRulesPath, []), }); } if (req.method === "PUT" && req.url === "/api/rules") { const body = await readBody(req); const rules = normalizeCustomRules(body.rules); writeJson(settings.customRulesPath, rules); const prevState = readJson(settings.statePath, {}); writeJson(settings.statePath, { ...prevState, rulesUpdatedAt: new Date().toISOString(), }); return sendJson(res, 200, { success: true, rules }); } if (req.method === "GET" && req.url === "/api/rules/conflicts") { const rules = readJson(settings.customRulesPath, []); return sendJson(res, 200, { success: true, conflicts: detectRuleConflicts(rules), }); } if (req.method === "GET" && req.url === "/api/device-rules") { const deviceProfiles = readDeviceProfiles(); return sendJson(res, 200, { success: true, deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles), }); } if (req.method === "PUT" && req.url === "/api/device-rules") { const body = await readBody(req); const rules = normalizeDeviceRules(body.deviceRules); const devices = []; for (const rule of rules) { rule.sourceIps.forEach((ip, index) => { devices.push({ id: `${rule.id}-${index}`, name: rule.name, enabled: rule.enabled, ip, mode: rule.outbound, }); }); } const profiles = writeDeviceProfiles({ defaultTransparentMode: "vpn", proxyDefaultMode: "vpn", devices, }); const prevState = readJson(settings.statePath, {}); writeJson(settings.statePath, { ...prevState, devicesUpdatedAt: new Date().toISOString(), }); return sendJson(res, 200, { success: true, ...profiles, deviceRules: legacyDeviceRulesFromProfiles(profiles), }); } if (req.method === "GET" && req.url === "/api/devices") { const profiles = readDeviceProfiles(); return sendJson(res, 200, { success: true, ...profiles }); } if (req.method === "PUT" && req.url === "/api/devices") { const body = await readBody(req); const input = body.devicesConfig || { defaultTransparentMode: body.defaultTransparentMode || body.defaultMode, proxyDefaultMode: body.proxyDefaultMode, devices: body.devices, }; const profiles = writeDeviceProfiles(input); const prevState = readJson(settings.statePath, {}); const devicesUpdatedAt = new Date().toISOString(); writeJson(settings.statePath, { ...prevState, devicesUpdatedAt, }); return sendJson(res, 200, { success: true, ...profiles, devicesUpdatedAt, }); } if (req.method === "GET" && req.url === "/api/rule-sets") { return sendJson(res, 200, { success: true, ruleSets: readJson(settings.customRuleSetsPath, []), }); } if (req.method === "PUT" && req.url === "/api/rule-sets") { const body = await readBody(req); const rawSets = Array.isArray(body.ruleSets) ? body.ruleSets : []; const normalized = rawSets .filter((rs) => rs && rs.tag && rs.url) .map((rs) => ({ tag: String(rs.tag).trim(), url: String(rs.url).trim(), format: rs.format === "source" ? "source" : "binary", })) .filter((rs) => RULE_SET_TAG_RE.test(rs.tag)); writeJson(settings.customRuleSetsPath, normalized); return sendJson(res, 200, { success: true, ruleSets: normalized }); } if (req.method === "POST" && req.url === "/api/rule-sets/lookup") { const body = await readBody(req); const url = String(body.url || "").trim(); const tag = String(body.tag || "").trim(); if (!url) return sendJson(res, 400, { success: false, error: "Укажите url" }); // Кеш — файл рядом с custom-rule-sets.json // Используем crypto hash чтобы избежать коллизий при одинаковом префиксе URL const cacheKey = crypto.createHash("sha1").update(url).digest("hex"); const cacheFile = path.join( settings.dataDir, `ruleset-cache-${cacheKey}.json`, ); const CACHE_TTL_MS = 3 * 60 * 60 * 1000; // 3 часа if (fs.existsSync(cacheFile)) { try { const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")); if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) { return sendJson(res, 200, { success: true, ...cached }); } } catch {} } // Скачать .srs во временный файл const tmpSrs = path.join(os.tmpdir(), `singbox-rs-${Date.now()}.srs`); const tmpJson = tmpSrs.replace(".srs", ".json"); try { const downloadedFrom = await downloadFile(url, tmpSrs); // Декомпилировать через sing-box rule-set decompile const dec = spawnSync( "sing-box", ["rule-set", "decompile", "--output", tmpJson, tmpSrs], { timeout: 15000, encoding: "utf8", }, ); if (dec.error) { return sendJson(res, 200, { success: false, error: `sing-box не найден или не запустился: ${dec.error.message}`, }); } if (dec.status !== 0) { return sendJson(res, 200, { success: false, error: `sing-box decompile завершился с ошибкой: ${dec.stderr || "неизвестная ошибка"}`, }); } const raw = JSON.parse(fs.readFileSync(tmpJson, "utf8")); // Плоский список записей из всех rules const rules = Array.isArray(raw.rules) ? raw.rules : []; const entries = []; for (const rule of rules) { if (Array.isArray(rule.domain)) entries.push( ...rule.domain.map((v) => ({ type: "domain", value: v })), ); if (Array.isArray(rule.domain_suffix)) entries.push( ...rule.domain_suffix.map((v) => ({ type: "suffix", value: v })), ); if (Array.isArray(rule.domain_keyword)) entries.push( ...rule.domain_keyword.map((v) => ({ type: "keyword", value: v })), ); if (Array.isArray(rule.ip_cidr)) entries.push( ...rule.ip_cidr.map((v) => ({ type: "cidr", value: v })), ); if (Array.isArray(rule.domain_regex)) entries.push( ...rule.domain_regex.map((v) => ({ type: "regex", value: v })), ); } const stats = { domain: entries.filter((e) => e.type === "domain").length, suffix: entries.filter((e) => e.type === "suffix").length, keyword: entries.filter((e) => e.type === "keyword").length, cidr: entries.filter((e) => e.type === "cidr").length, regex: entries.filter((e) => e.type === "regex").length, total: entries.length, }; const result = { tag, url, downloadedFrom, entries, stats, cachedAt: new Date().toISOString(), }; writeJson(cacheFile, result); return sendJson(res, 200, { success: true, ...result }); } catch (err) { return sendJson(res, 200, { success: false, error: err.message }); } finally { for (const f of [tmpSrs, tmpJson]) { try { fs.unlinkSync(f); } catch {} } } } if (req.method === "GET" && req.url === "/api/rule-sets/sagernet-catalog") { const cacheFile = path.join( settings.dataDir, "sagernet-catalog-cache.json", ); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 часа if (fs.existsSync(cacheFile)) { try { const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")); if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) { return sendJson(res, 200, { success: true, ...cached }); } } catch {} } try { const headers = { "User-Agent": "vpn-proxy-app" }; const [gsRes, giRes] = await Promise.all([ fetch( "https://api.github.com/repos/SagerNet/sing-geosite/git/trees/rule-set?recursive=1", { headers }, ), fetch( "https://api.github.com/repos/SagerNet/sing-geoip/git/trees/rule-set?recursive=1", { headers }, ), ]); if (!gsRes.ok || !giRes.ok) { throw new Error( `GitHub API недоступен: geosite=${gsRes.status}, geoip=${giRes.status}`, ); } const gsData = await gsRes.json(); const giData = await giRes.json(); const geosite = (gsData.tree || []) .filter((f) => f.path.endsWith(".srs")) .map((f) => f.path.replace(".srs", "")) .sort(); const geoip = (giData.tree || []) .filter((f) => f.path.endsWith(".srs")) .map((f) => f.path.replace(".srs", "")) .sort(); if (!geosite.length && !geoip.length) { throw new Error("GitHub API вернул пустой каталог rule-set"); } const result = { geosite, geoip, cachedAt: new Date().toISOString() }; writeJson(cacheFile, result); return sendJson(res, 200, { success: true, ...result }); } catch (err) { const result = { ...FALLBACK_RULE_SET_CATALOG, cachedAt: new Date().toISOString(), fallback: true, warning: `GitHub каталог не загрузился, показан встроенный список: ${err.message}`, }; return sendJson(res, 200, { success: true, ...result }); } } if (req.method === "POST" && req.url === "/api/route/check") { const body = await readBody(req); const host = String(body.host || "").trim(); let ip = String(body.ip || "").trim(); const port = body.port !== undefined && body.port !== "" ? Number(body.port) : undefined; const network = String(body.network || "").trim() || undefined; const sourceIp = String(body.sourceIp || "").trim() || undefined; const inbound = String(body.inbound || "").trim() || undefined; if (!host && !ip) { return sendJson(res, 400, { success: false, error: "Укажите домен или IP", }); } let resolvedFrom = null; if (!ip && host) { const resolved = await resolveHost(host); if (resolved) { ip = resolved; resolvedFrom = host; } } const rules = readJson(settings.customRulesPath, []); const state = readJson(settings.statePath, {}); const vpnTag = state.selectedTag || "vpn-out"; const result = matchRoute({ host, ip, port, network, sourceIp, inbound }, rules, { routingRuDirect: settings.routingRuDirect, vpnTag, deviceProfiles: readDeviceProfiles(), }); return sendJson(res, 200, { success: true, result, resolvedIp: ip || null, resolvedFrom, }); } if (req.method === "POST" && req.url === "/api/servers/ping") { const body = await readBody(req); const host = String(body.host || "").trim(); const port = Number(body.port); if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { return sendJson(res, 400, { success: false, error: "Требуются host и port", }); } const result = await tcpPing(host, port, Number(body.timeout) || 3000); return sendJson(res, 200, { success: true, ...result }); } if (req.method === "POST" && req.url === "/api/servers/ping-all") { const cached = readJson(settings.subscriptionCachePath, null); const state = readJson(settings.statePath, {}); const servers = state.servers || cached?.servers || []; const results = await Promise.all( servers.map(async (server) => { const ping = await tcpPing(server.server, server.server_port, 3000); return { tag: server.tag, ...ping, checkedAt: new Date().toISOString(), }; }), ); return sendJson(res, 200, { success: true, results }); } if (req.method === "POST" && req.url === "/api/config/validate") { const cached = readJson(settings.subscriptionCachePath, null); const customRules = readJson(settings.customRulesPath, []); const stateData = readJson(settings.statePath, {}); const tag = stateData.selectedTag; if (!cached?.config) { return sendJson(res, 200, { success: true, valid: false, error: "Подписка не загружена", }); } if (!tag) { return sendJson(res, 200, { success: true, valid: false, error: "Сервер не выбран", }); } try { buildGatewayConfig({ ...cached.config, customRules }, tag); } catch (err) { return sendJson(res, 200, { success: true, valid: false, error: err.message, }); } if (fs.existsSync(settings.configPath)) { try { checkSingboxConfig(); return sendJson(res, 200, { success: true, valid: true }); } catch (err) { return sendJson(res, 200, { success: true, valid: false, error: err.message, }); } } return sendJson(res, 200, { success: true, valid: true, note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)", }); } if (req.method === "POST" && req.url === "/api/apply/rollback") { const stateData = readJson(settings.statePath, {}); const target = stateData.previousTag; if (!target) { return sendJson(res, 400, { success: false, error: "Нет предыдущего сервера для отката", }); } await applySelectedServer(target); return sendJson(res, 200, { success: true, selectedTag: target }); } if (req.method === "POST" && req.url === "/api/subscription/fetch") { const body = await readBody(req); const url = String(body.url || "").trim(); if (!url) return sendJson(res, 400, { success: false, error: "Укажите subscription URL", }); const parsed = await fetchSubscription(url); writeJson(settings.subscriptionCachePath, { url, ...parsed }); const prevState = readJson(settings.statePath, {}); writeJson(settings.statePath, { ...prevState, subscriptionUrl: url, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, }); return sendJson(res, 200, { success: true, ...parsed }); } if (req.method === "DELETE" && req.url === "/api/subscription") { if (fs.existsSync(settings.subscriptionCachePath)) fs.rmSync(settings.subscriptionCachePath); const prevState = readJson(settings.statePath, {}); delete prevState.subscriptionUrl; delete prevState.servers; delete prevState.userInfo; delete prevState.fetchedAt; delete prevState.selectedTag; delete prevState.appliedAt; writeJson(settings.statePath, prevState); await stopSingbox(); removeSingboxConfig(); pushLog("info", "Подписка удалена, sing-box остановлен"); return sendJson(res, 200, { success: true }); } if (req.method === "POST" && req.url === "/api/apply") { const body = await readBody(req); const selectedTag = String(body.selectedTag || "").trim(); if (!selectedTag) return sendJson(res, 400, { success: false, error: "selectedTag обязателен", }); await applySelectedServer(selectedTag); return sendJson(res, 200, { success: true, selectedTag, configPath: settings.configPath, singboxRunning: Boolean(singboxProcess), }); } if (req.method === "POST" && req.url === "/api/singbox/stop") { await stopSingbox(); pushLog("info", "sing-box остановлен пользователем"); return sendJson(res, 200, { success: true, singboxRunning: false }); } if (req.method === "POST" && req.url === "/api/singbox/restart") { if (!fs.existsSync(settings.configPath)) { return sendJson(res, 400, { success: false, error: "Конфиг отсутствует — сначала примените сервер", }); } await startSingbox(); pushLog("info", "sing-box перезапущен пользователем"); return sendJson(res, 200, { success: true, singboxRunning: Boolean(singboxProcess), }); } if (req.method === "POST" && req.url === "/api/singbox/clear") { await stopSingbox(); removeSingboxConfig(); const prevState = readJson(settings.statePath, {}); delete prevState.selectedTag; delete prevState.appliedAt; writeJson(settings.statePath, prevState); pushLog("info", "Конфиг sing-box удалён, процесс остановлен"); return sendJson(res, 200, { success: true, singboxRunning: false }); } return sendJson(res, 404, { success: false, error: "Не найдено" }); } const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json; charset=utf-8", }; function serveStatic(req, res) { const requestPath = new URL(req.url, `http://localhost:${settings.port}`) .pathname; const cleanPath = requestPath === "/" ? "/index.html" : requestPath; const filePath = path.resolve(settings.distDir, `.${cleanPath}`); const distRoot = path.resolve(settings.distDir); if (!filePath.startsWith(distRoot)) { res.writeHead(403); return res.end("Forbidden"); } const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() ? filePath : path.join(settings.distDir, "index.html"); const ext = path.extname(finalPath); res.writeHead(200, { "content-type": mime[ext] || "application/octet-stream", }); fs.createReadStream(finalPath).pipe(res); } const server = http.createServer(async (req, res) => { try { if (req.url?.startsWith("/api/")) { return await handleApi(req, res); } return serveStatic(req, res); } catch (error) { console.error("[control] request failed", error); return sendJson(res, 500, { success: false, error: error.message || String(error), }); } }); process.on("SIGTERM", async () => { await stopSingbox(); process.exit(0); }); process.on("SIGINT", async () => { await stopSingbox(); process.exit(0); }); // При старте пробуем подхватить уже запущенный sing-box const existingPid = readSingboxPid(); 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}`); }); } server.listen(settings.port, "0.0.0.0", () => { console.log(`[control] gateway UI слушает :${settings.port}`); });