import http from "node:http"; import fs from "node:fs"; import path from "node:path"; 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 { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; const APPLY_HISTORY_LIMIT = 10; fs.mkdirSync(settings.dataDir, { recursive: true }); const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid"); let singboxProcess = null; let singboxStartedAt = null; const LOG_BUFFER_SIZE = 500; const logBuffer = []; const logSubscribers = new Set(); 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 {} } } // 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 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 { subscriptionUrl, ...rest } = state; return { mode: "gateway", port: settings.port, proxyPort: settings.proxyPort, tproxyPort: settings.tproxyPort, routingRuDirect: settings.routingRuDirect, configExists: fs.existsSync(settings.configPath), singboxRunning: Boolean(singboxProcess), singboxStartedAt, subscriptionHost: maskSubscriptionUrl(subscriptionUrl), hasSubscription: Boolean(subscriptionUrl), customRules, appliedHistory: state.appliedHistory || [], rulesUpdatedAt: state.rulesUpdatedAt || null, rulesAppliedAt: state.rulesAppliedAt || null, ...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) => /^[a-z0-9][a-z0-9-]*$/i.test(tag), ), })); } async function applySelectedServer(selectedTag) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) { throw new Error("Сначала загрузите подписку"); } const customRules = readJson(settings.customRulesPath, []); const generated = buildGatewayConfig( { ...cached.config, customRules }, selectedTag, ); 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/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/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) => /^[a-z0-9][a-z0-9-]*$/i.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 const cacheKey = Buffer.from(url).toString("base64url").slice(0, 48); 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 { await new Promise((resolve, reject) => { let stderr = ""; // Скачиваем через curl (доступен в контейнере) const dl = spawn("curl", [ "-fsSL", "--max-time", "30", url, "-o", tmpSrs, ]); dl.stderr.on("data", (d) => { stderr += d; }); dl.on("close", (code) => { if (code !== 0) reject(new Error(`curl завершился с кодом ${code}: ${stderr}`)); else resolve(); }); }); // Декомпилировать через sing-box rule-set decompile const dec = spawnSync( "sing-box", ["rule-set", "decompile", "--output", tmpJson, tmpSrs], { timeout: 15000, encoding: "utf8", }, ); 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, 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 }, ), ]); 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(); const result = { geosite, geoip, cachedAt: new Date().toISOString() }; writeJson(cacheFile, result); return sendJson(res, 200, { success: true, ...result }); } catch (err) { return sendJson(res, 500, { success: false, error: err.message }); } } 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; 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 cached = readJson(settings.subscriptionCachePath, null); const state = readJson(settings.statePath, {}); const vpnTag = state.selectedTag || "vpn-out"; const result = matchRoute({ host, ip, port, network }, rules, { routingRuDirect: settings.routingRuDirect, vpnTag, }); 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(); 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}`); });