All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
926 lines
28 KiB
JavaScript
926 lines
28 KiB
JavaScript
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[<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 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}`);
|
||
});
|