feat: добавлены функции для работы с PID sing-box
Refs: None
This commit is contained in:
@@ -17,6 +17,8 @@ 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;
|
||||
@@ -47,6 +49,61 @@ function parseSingboxLevel(line, fallback) {
|
||||
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");
|
||||
@@ -167,6 +224,7 @@ async function startSingbox() {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
singboxStartedAt = new Date().toISOString();
|
||||
saveSingboxPid(singboxProcess.pid);
|
||||
pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);
|
||||
|
||||
captureStream(singboxProcess.stdout, "info");
|
||||
@@ -176,6 +234,7 @@ async function startSingbox() {
|
||||
pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
|
||||
singboxProcess = null;
|
||||
singboxStartedAt = null;
|
||||
removeSingboxPid();
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -250,9 +309,17 @@ async function applySelectedServer(selectedTag) {
|
||||
|
||||
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);
|
||||
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,
|
||||
@@ -332,18 +399,27 @@ async function handleApi(req, res) {
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/rules/conflicts") {
|
||||
const rules = readJson(settings.customRulesPath, []);
|
||||
return sendJson(res, 200, { success: true, conflicts: detectRuleConflicts(rules) });
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
conflicts: detectRuleConflicts(rules),
|
||||
});
|
||||
}
|
||||
|
||||
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 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" });
|
||||
return sendJson(res, 400, {
|
||||
success: false,
|
||||
error: "Укажите домен или IP",
|
||||
});
|
||||
}
|
||||
|
||||
let resolvedFrom = null;
|
||||
@@ -359,13 +435,17 @@ async function handleApi(req, res) {
|
||||
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 },
|
||||
);
|
||||
const result = matchRoute({ host, ip, port, network }, rules, {
|
||||
routingRuDirect: settings.routingRuDirect,
|
||||
vpnTag,
|
||||
});
|
||||
|
||||
return sendJson(res, 200, { success: true, result, resolvedIp: ip || null, resolvedFrom });
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
result,
|
||||
resolvedIp: ip || null,
|
||||
resolvedFrom,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/servers/ping") {
|
||||
@@ -373,7 +453,10 @@ async function handleApi(req, res) {
|
||||
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" });
|
||||
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 });
|
||||
@@ -386,7 +469,11 @@ async function handleApi(req, res) {
|
||||
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 {
|
||||
tag: server.tag,
|
||||
...ping,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return sendJson(res, 200, { success: true, results });
|
||||
@@ -399,16 +486,28 @@ async function handleApi(req, res) {
|
||||
const tag = stateData.selectedTag;
|
||||
|
||||
if (!cached?.config) {
|
||||
return sendJson(res, 200, { success: true, valid: false, error: "Подписка не загружена" });
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
valid: false,
|
||||
error: "Подписка не загружена",
|
||||
});
|
||||
}
|
||||
if (!tag) {
|
||||
return sendJson(res, 200, { success: true, valid: false, error: "Сервер не выбран" });
|
||||
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 });
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
valid: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
@@ -416,17 +515,28 @@ async function handleApi(req, res) {
|
||||
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: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sendJson(res, 200, { success: true, valid: true, note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)" });
|
||||
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: "Нет предыдущего сервера для отката" });
|
||||
return sendJson(res, 400, {
|
||||
success: false,
|
||||
error: "Нет предыдущего сервера для отката",
|
||||
});
|
||||
}
|
||||
await applySelectedServer(target);
|
||||
return sendJson(res, 200, { success: true, selectedTag: target });
|
||||
@@ -583,10 +693,17 @@ process.on("SIGINT", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
await startSingbox().catch((error) => {
|
||||
console.warn(`[control] sing-box не запущен: ${error.message}`);
|
||||
pushLog("error", `sing-box не запущен при старте: ${error.message}`);
|
||||
});
|
||||
// При старте пробуем подхватить уже запущенный 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}`);
|
||||
|
||||
@@ -21,9 +21,15 @@ export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||
};
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.once("connect", () => finish({ ok: true, latency: Date.now() - start }));
|
||||
socket.once("timeout", () => finish({ ok: false, latency: null, error: "timeout" }));
|
||||
socket.once("error", (err) => finish({ ok: false, latency: null, error: err.code || err.message }));
|
||||
socket.once("connect", () =>
|
||||
finish({ ok: true, latency: Date.now() - start }),
|
||||
);
|
||||
socket.once("timeout", () =>
|
||||
finish({ ok: false, latency: null, error: "timeout" }),
|
||||
);
|
||||
socket.once("error", (err) =>
|
||||
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||
);
|
||||
|
||||
try {
|
||||
socket.connect(port, host);
|
||||
|
||||
@@ -7,8 +7,14 @@ import net from "node:net";
|
||||
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
|
||||
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
|
||||
);
|
||||
}
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
@@ -30,7 +36,13 @@ function ipInCidr(ip, cidr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const PRIVATE_CIDRS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"];
|
||||
const PRIVATE_CIDRS = [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
];
|
||||
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
@@ -122,7 +134,8 @@ export function matchRoute(target, customRules, options = {}) {
|
||||
for (let i = 0; i < rules.length; i += 1) {
|
||||
const rule = rules[i];
|
||||
if (ruleMatches(rule, target)) {
|
||||
const outbound = rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
|
||||
const outbound =
|
||||
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
|
||||
return {
|
||||
matched: "custom",
|
||||
ruleIndex: i,
|
||||
@@ -182,7 +195,11 @@ export function detectRuleConflicts(rules) {
|
||||
// Точные домены покрываются prev.suffix
|
||||
for (const d of cur.domains || []) {
|
||||
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
|
||||
overlaps.push({ kind: "domain", value: d, by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}` });
|
||||
overlaps.push({
|
||||
kind: "domain",
|
||||
value: d,
|
||||
by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}`,
|
||||
});
|
||||
}
|
||||
if ((prev.domains || []).includes(d)) {
|
||||
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
|
||||
@@ -191,8 +208,16 @@ export function detectRuleConflicts(rules) {
|
||||
|
||||
// Суффиксы покрываются более общим суффиксом prev
|
||||
for (const s of cur.domainSuffixes || []) {
|
||||
if ((prev.domainSuffixes || []).some((ps) => hostMatchesSuffix(s, ps) && ps !== s)) {
|
||||
overlaps.push({ kind: "suffix", value: s, by: "более общий суффикс" });
|
||||
if (
|
||||
(prev.domainSuffixes || []).some(
|
||||
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
|
||||
)
|
||||
) {
|
||||
overlaps.push({
|
||||
kind: "suffix",
|
||||
value: s,
|
||||
by: "более общий суффикс",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user