diff --git a/src/server/index.js b/src/server/index.js index 4b4cb68..e83bba9 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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}`); diff --git a/src/server/ping.js b/src/server/ping.js index 2705f7f..b64b0b5 100644 --- a/src/server/ping.js +++ b/src/server/ping.js @@ -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); diff --git a/src/server/routeMatcher.js b/src/server/routeMatcher.js index 5a64826..a556211 100644 --- a/src/server/routeMatcher.js +++ b/src/server/routeMatcher.js @@ -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: "более общий суффикс", + }); } } diff --git a/src/web/styles.css b/src/web/styles.css index a872845..d467c14 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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 { + --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; /* Surfaces */ @@ -62,7 +65,7 @@ html, body, #root { body { margin: 0; - font-family: 'IBM Plex Sans', sans-serif; + font-family: var(--font-ui); font-size: 14px; line-height: 1.5; color: var(--text); @@ -79,7 +82,7 @@ a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } h1, h2, h3, h4 { - font-family: 'Space Grotesk', sans-serif; + font-family: var(--font-head); margin: 0; font-weight: 600; letter-spacing: -0.01em; @@ -92,7 +95,7 @@ h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spa p { margin: 0; } small { font-size: 12px; color: var(--muted); } code, .mono { - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; } @@ -142,7 +145,7 @@ code, .mono { height: var(--topbar-h); } .topbar-brand { - font-family: 'Space Grotesk', sans-serif; + font-family: var(--font-head); font-weight: 700; font-size: 16px; letter-spacing: -0.01em; @@ -359,7 +362,7 @@ code, .mono { border-color: var(--accent); 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 { display: flex; @@ -487,7 +490,7 @@ code, .mono { background: var(--surface-3); border: 1px solid var(--border); border-radius: 8px; - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--text); } @@ -509,7 +512,7 @@ code, .mono { border: none; outline: none; color: var(--text); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; padding: 4px 6px; } @@ -632,7 +635,7 @@ code, .mono { border: 1px solid var(--border); border-radius: var(--radius-input); padding: var(--space-3); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; line-height: 1.55; overflow-y: auto; @@ -687,7 +690,7 @@ code, .mono { .text-success { color: var(--success); } .text-warning { color: var(--warning); } .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; } .divider { @@ -701,7 +704,7 @@ code, .mono { border: 1px solid var(--border); border-radius: var(--radius-input); padding: var(--space-3); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 12px; line-height: 1.5; margin: 0; @@ -718,7 +721,7 @@ code, .mono { padding: 6px 8px; border-radius: 6px; color: var(--subtle); - font-family: 'JetBrains Mono', monospace; + font-family: var(--font-mono); font-size: 14px; } .drag-handle:hover { color: var(--text); background: var(--surface-2); } @@ -771,7 +774,7 @@ code, .mono { font-size: 12px; } .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 { background: var(--surface-2); diff --git a/src/web/utils/country.js b/src/web/utils/country.js index 862c319..9db9455 100644 --- a/src/web/utils/country.js +++ b/src/web/utils/country.js @@ -5,7 +5,11 @@ const COUNTRIES = [ { 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(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(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" }, { re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },