1456 lines
44 KiB
JavaScript
1456 lines
44 KiB
JavaScript
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[<n>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}`);
|
||
});
|