feat: добавлены функции для работы с PID sing-box

Refs: None
This commit is contained in:
2026-05-08 19:41:17 +03:00
parent 8476ab16e5
commit 0cd898d1c1
5 changed files with 203 additions and 48 deletions

View File

@@ -17,6 +17,8 @@ const APPLY_HISTORY_LIMIT = 10;
fs.mkdirSync(settings.dataDir, { recursive: true }); fs.mkdirSync(settings.dataDir, { recursive: true });
const SINGBOX_PID_FILE = path.join(settings.dataDir, 'singbox.pid');
let singboxProcess = null; let singboxProcess = null;
let singboxStartedAt = null; let singboxStartedAt = null;
const LOG_BUFFER_SIZE = 500; const LOG_BUFFER_SIZE = 500;
@@ -47,6 +49,61 @@ function parseSingboxLevel(line, fallback) {
return l; // trace, debug, info, 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) { function captureStream(stream, fallbackLevel) {
let remainder = ""; let remainder = "";
stream.setEncoding("utf8"); stream.setEncoding("utf8");
@@ -167,6 +224,7 @@ async function startSingbox() {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
singboxStartedAt = new Date().toISOString(); singboxStartedAt = new Date().toISOString();
saveSingboxPid(singboxProcess.pid);
pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`); pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);
captureStream(singboxProcess.stdout, "info"); captureStream(singboxProcess.stdout, "info");
@@ -176,6 +234,7 @@ async function startSingbox() {
pushLog("info", `sing-box завершён: code=${code} signal=${signal}`); pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess = null; singboxProcess = null;
singboxStartedAt = null; singboxStartedAt = null;
removeSingboxPid();
}); });
return true; return true;
@@ -250,9 +309,17 @@ async function applySelectedServer(selectedTag) {
const prevState = readJson(settings.statePath, {}); const prevState = readJson(settings.statePath, {});
const now = new Date().toISOString(); const now = new Date().toISOString();
const previousTag = prevState.selectedTag && prevState.selectedTag !== selectedTag ? prevState.selectedTag : prevState.previousTag || null; const previousTag =
const history = Array.isArray(prevState.appliedHistory) ? prevState.appliedHistory : []; prevState.selectedTag && prevState.selectedTag !== selectedTag
const nextHistory = [{ tag: selectedTag, at: now }, ...history.filter((h) => h.tag !== selectedTag)].slice(0, APPLY_HISTORY_LIMIT); ? 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, { writeJson(settings.statePath, {
...prevState, ...prevState,
@@ -332,18 +399,27 @@ async function handleApi(req, res) {
if (req.method === "GET" && req.url === "/api/rules/conflicts") { if (req.method === "GET" && req.url === "/api/rules/conflicts") {
const rules = readJson(settings.customRulesPath, []); 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") { if (req.method === "POST" && req.url === "/api/route/check") {
const body = await readBody(req); const body = await readBody(req);
const host = String(body.host || "").trim(); const host = String(body.host || "").trim();
let ip = String(body.ip || "").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; const network = String(body.network || "").trim() || undefined;
if (!host && !ip) { if (!host && !ip) {
return sendJson(res, 400, { success: false, error: "Укажите домен или IP" }); return sendJson(res, 400, {
success: false,
error: "Укажите домен или IP",
});
} }
let resolvedFrom = null; let resolvedFrom = null;
@@ -359,13 +435,17 @@ async function handleApi(req, res) {
const cached = readJson(settings.subscriptionCachePath, null); const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {}); const state = readJson(settings.statePath, {});
const vpnTag = state.selectedTag || "vpn-out"; const vpnTag = state.selectedTag || "vpn-out";
const result = matchRoute( const result = matchRoute({ host, ip, port, network }, rules, {
{ host, ip, port, network }, routingRuDirect: settings.routingRuDirect,
rules, vpnTag,
{ 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") { 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 host = String(body.host || "").trim();
const port = Number(body.port); const port = Number(body.port);
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { 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); const result = await tcpPing(host, port, Number(body.timeout) || 3000);
return sendJson(res, 200, { success: true, ...result }); return sendJson(res, 200, { success: true, ...result });
@@ -386,7 +469,11 @@ async function handleApi(req, res) {
const results = await Promise.all( const results = await Promise.all(
servers.map(async (server) => { servers.map(async (server) => {
const ping = await tcpPing(server.server, server.server_port, 3000); 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 }); return sendJson(res, 200, { success: true, results });
@@ -399,16 +486,28 @@ async function handleApi(req, res) {
const tag = stateData.selectedTag; const tag = stateData.selectedTag;
if (!cached?.config) { if (!cached?.config) {
return sendJson(res, 200, { success: true, valid: false, error: "Подписка не загружена" }); return sendJson(res, 200, {
success: true,
valid: false,
error: "Подписка не загружена",
});
} }
if (!tag) { if (!tag) {
return sendJson(res, 200, { success: true, valid: false, error: "Сервер не выбран" }); return sendJson(res, 200, {
success: true,
valid: false,
error: "Сервер не выбран",
});
} }
try { try {
buildGatewayConfig({ ...cached.config, customRules }, tag); buildGatewayConfig({ ...cached.config, customRules }, tag);
} catch (err) { } 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)) { if (fs.existsSync(settings.configPath)) {
@@ -416,17 +515,28 @@ async function handleApi(req, res) {
checkSingboxConfig(); checkSingboxConfig();
return sendJson(res, 200, { success: true, valid: true }); return sendJson(res, 200, { success: true, valid: true });
} catch (err) { } 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") { if (req.method === "POST" && req.url === "/api/apply/rollback") {
const stateData = readJson(settings.statePath, {}); const stateData = readJson(settings.statePath, {});
const target = stateData.previousTag; const target = stateData.previousTag;
if (!target) { if (!target) {
return sendJson(res, 400, { success: false, error: "Нет предыдущего сервера для отката" }); return sendJson(res, 400, {
success: false,
error: "Нет предыдущего сервера для отката",
});
} }
await applySelectedServer(target); await applySelectedServer(target);
return sendJson(res, 200, { success: true, selectedTag: target }); return sendJson(res, 200, { success: true, selectedTag: target });
@@ -583,10 +693,17 @@ process.on("SIGINT", async () => {
process.exit(0); process.exit(0);
}); });
await startSingbox().catch((error) => { // При старте пробуем подхватить уже запущенный sing-box
console.warn(`[control] sing-box не запущен: ${error.message}`); const existingPid = readSingboxPid();
pushLog("error", `sing-box не запущен при старте: ${error.message}`); 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", () => { server.listen(settings.port, "0.0.0.0", () => {
console.log(`[control] gateway UI слушает :${settings.port}`); console.log(`[control] gateway UI слушает :${settings.port}`);

View File

@@ -21,9 +21,15 @@ export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
}; };
socket.setTimeout(timeout); socket.setTimeout(timeout);
socket.once("connect", () => finish({ ok: true, latency: Date.now() - start })); socket.once("connect", () =>
socket.once("timeout", () => finish({ ok: false, latency: null, error: "timeout" })); finish({ ok: true, latency: Date.now() - start }),
socket.once("error", (err) => finish({ ok: false, latency: null, error: err.code || err.message })); );
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 { try {
socket.connect(port, host); socket.connect(port, host);

View File

@@ -7,8 +7,14 @@ import net from "node:net";
function ipv4ToInt(ip) { function ipv4ToInt(ip) {
const parts = ip.split(".").map((x) => Number.parseInt(x, 10)); 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; if (
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; 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) { function ipInCidr(ip, cidr) {
@@ -30,7 +36,13 @@ function ipInCidr(ip, cidr) {
return false; 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) { function isPrivateIp(ip) {
if (!ip) return false; if (!ip) return false;
@@ -122,7 +134,8 @@ export function matchRoute(target, customRules, options = {}) {
for (let i = 0; i < rules.length; i += 1) { for (let i = 0; i < rules.length; i += 1) {
const rule = rules[i]; const rule = rules[i];
if (ruleMatches(rule, target)) { if (ruleMatches(rule, target)) {
const outbound = rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound; const outbound =
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
return { return {
matched: "custom", matched: "custom",
ruleIndex: i, ruleIndex: i,
@@ -182,7 +195,11 @@ export function detectRuleConflicts(rules) {
// Точные домены покрываются prev.suffix // Точные домены покрываются prev.suffix
for (const d of cur.domains || []) { for (const d of cur.domains || []) {
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) { 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)) { if ((prev.domains || []).includes(d)) {
overlaps.push({ kind: "domain", value: d, by: "точный домен" }); overlaps.push({ kind: "domain", value: d, by: "точный домен" });
@@ -191,8 +208,16 @@ export function detectRuleConflicts(rules) {
// Суффиксы покрываются более общим суффиксом prev // Суффиксы покрываются более общим суффиксом prev
for (const s of cur.domainSuffixes || []) { for (const s of cur.domainSuffixes || []) {
if ((prev.domainSuffixes || []).some((ps) => hostMatchesSuffix(s, ps) && ps !== s)) { if (
overlaps.push({ kind: "suffix", value: s, by: "более общий суффикс" }); (prev.domainSuffixes || []).some(
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
)
) {
overlaps.push({
kind: "suffix",
value: s,
by: "более общий суффикс",
});
} }
} }

View File

@@ -1,6 +1,9 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); /* Системные шрифты — без загрузки из интернета */
:root { :root {
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, sans-serif;
--font-head: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Cascadia Code', Consolas, 'Liberation Mono', Menlo, monospace;
color-scheme: dark; color-scheme: dark;
/* Surfaces */ /* Surfaces */
@@ -62,7 +65,7 @@ html, body, #root {
body { body {
margin: 0; margin: 0;
font-family: 'IBM Plex Sans', sans-serif; font-family: var(--font-ui);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: var(--text); color: var(--text);
@@ -79,7 +82,7 @@ a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
h1, h2, h3, h4 { h1, h2, h3, h4 {
font-family: 'Space Grotesk', sans-serif; font-family: var(--font-head);
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -92,7 +95,7 @@ h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spa
p { margin: 0; } p { margin: 0; }
small { font-size: 12px; color: var(--muted); } small { font-size: 12px; color: var(--muted); }
code, .mono { code, .mono {
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
} }
@@ -142,7 +145,7 @@ code, .mono {
height: var(--topbar-h); height: var(--topbar-h);
} }
.topbar-brand { .topbar-brand {
font-family: 'Space Grotesk', sans-serif; font-family: var(--font-head);
font-weight: 700; font-weight: 700;
font-size: 16px; font-size: 16px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -359,7 +362,7 @@ code, .mono {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim); box-shadow: 0 0 0 3px var(--accent-dim);
} }
.textarea { min-height: 80px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 12px; } .textarea { min-height: 80px; resize: vertical; font-family: var(--font-mono); font-size: 12px; }
.field { .field {
display: flex; display: flex;
@@ -487,7 +490,7 @@ code, .mono {
background: var(--surface-3); background: var(--surface-3);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: var(--text); color: var(--text);
} }
@@ -509,7 +512,7 @@ code, .mono {
border: none; border: none;
outline: none; outline: none;
color: var(--text); color: var(--text);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
padding: 4px 6px; padding: 4px 6px;
} }
@@ -632,7 +635,7 @@ code, .mono {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-input); border-radius: var(--radius-input);
padding: var(--space-3); padding: var(--space-3);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
overflow-y: auto; overflow-y: auto;
@@ -687,7 +690,7 @@ code, .mono {
.text-success { color: var(--success); } .text-success { color: var(--success); }
.text-warning { color: var(--warning); } .text-warning { color: var(--warning); }
.text-danger { color: var(--danger); } .text-danger { color: var(--danger); }
.text-mono { font-family: 'JetBrains Mono', monospace; } .text-mono { font-family: var(--font-mono); }
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.divider { .divider {
@@ -701,7 +704,7 @@ code, .mono {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-input); border-radius: var(--radius-input);
padding: var(--space-3); padding: var(--space-3);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
@@ -718,7 +721,7 @@ code, .mono {
padding: 6px 8px; padding: 6px 8px;
border-radius: 6px; border-radius: 6px;
color: var(--subtle); color: var(--subtle);
font-family: 'JetBrains Mono', monospace; font-family: var(--font-mono);
font-size: 14px; font-size: 14px;
} }
.drag-handle:hover { color: var(--text); background: var(--surface-2); } .drag-handle:hover { color: var(--text); background: var(--surface-2); }
@@ -771,7 +774,7 @@ code, .mono {
font-size: 12px; font-size: 12px;
} }
.event-row:hover { background: var(--surface-2); } .event-row:hover { background: var(--surface-2); }
.event-row .event-time { color: var(--subtle); font-family: 'JetBrains Mono', monospace; font-size: 11px; } .event-row .event-time { color: var(--subtle); font-family: var(--font-mono); font-size: 11px; }
.route-result { .route-result {
background: var(--surface-2); background: var(--surface-2);

View File

@@ -5,7 +5,11 @@ const COUNTRIES = [
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" }, { re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" }, { re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" }, { re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
{ re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i, code: "US", flag: "🇺🇸" }, {
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
code: "US",
flag: "🇺🇸",
},
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" }, { re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" }, { re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" }, { re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },