Files
harbor-net/src/server/index.js
Dmitriy Petrov bb7250e4ac
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
feat: добавлена возможность поиска и отображения rule-sets из каталога SagerNet
Refs: None
2026-05-08 20:38:27 +03:00

926 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});