style: исправлены стили и форматирование кода
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s

This commit is contained in:
2026-05-08 18:23:56 +03:00
parent 8789496ae6
commit 1ed79c3a1e
8 changed files with 320 additions and 211 deletions

View File

@@ -1,15 +1,15 @@
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 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 {
buildGatewayConfig,
writeSingboxConfig,
readSingboxConfig,
removeSingboxConfig,
} from './singbox.js';
} from "./singbox.js";
fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -31,31 +31,31 @@ function pushLog(level, line) {
}
function captureStream(stream, level) {
let remainder = '';
stream.setEncoding('utf8');
stream.on('data', (chunk) => {
let remainder = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
const data = remainder + chunk;
const lines = data.split(/\r?\n/);
remainder = lines.pop() || '';
remainder = lines.pop() || "";
for (const line of lines) {
if (!line) continue;
process.stdout.write(`[sing-box:${level}] ${line}\n`);
pushLog(level, line);
}
});
stream.on('end', () => {
stream.on("end", () => {
if (remainder) {
process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
pushLog(level, remainder);
}
remainder = '';
remainder = "";
});
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
@@ -63,11 +63,11 @@ function readJson(filePath, fallback) {
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function maskSubscriptionUrl(url) {
if (!url) return '';
if (!url) return "";
try {
const parsed = new URL(url);
return `${parsed.hostname}/...`;
@@ -79,8 +79,8 @@ function maskSubscriptionUrl(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),
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}
@@ -88,26 +88,28 @@ function sendJson(res, statusCode, payload) {
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
if (!chunks.length) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
reject(new Error('Невалидный JSON в теле запроса'));
reject(new Error("Невалидный JSON в теле запроса"));
}
});
req.on('error', reject);
req.on("error", reject);
});
}
function checkSingboxConfig() {
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
encoding: 'utf8',
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());
throw new Error(
(result.stderr || result.stdout || "sing-box check failed").trim(),
);
}
}
@@ -123,16 +125,16 @@ function stopSingbox() {
singboxStartedAt = null;
const timeout = setTimeout(() => {
current.kill('SIGKILL');
current.kill("SIGKILL");
resolve();
}, 4000);
current.once('exit', () => {
current.once("exit", () => {
clearTimeout(timeout);
resolve();
});
current.kill('SIGTERM');
current.kill("SIGTERM");
});
}
@@ -142,17 +144,17 @@ async function startSingbox() {
checkSingboxConfig();
await stopSingbox();
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
singboxProcess = spawn("sing-box", ["run", "-c", settings.configPath], {
stdio: ["ignore", "pipe", "pipe"],
});
singboxStartedAt = new Date().toISOString();
pushLog('info', `sing-box запущен (pid=${singboxProcess.pid})`);
pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);
captureStream(singboxProcess.stdout, 'info');
captureStream(singboxProcess.stderr, 'error');
captureStream(singboxProcess.stdout, "info");
captureStream(singboxProcess.stderr, "error");
singboxProcess.once('exit', (code, signal) => {
pushLog('info', `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess.once("exit", (code, signal) => {
pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess = null;
singboxStartedAt = null;
});
@@ -165,7 +167,7 @@ function publicState() {
const customRules = readJson(settings.customRulesPath, []);
const { subscriptionUrl, ...rest } = state;
return {
mode: 'gateway',
mode: "gateway",
port: settings.port,
proxyPort: settings.proxyPort,
tproxyPort: settings.tproxyPort,
@@ -182,9 +184,9 @@ function publicState() {
function normalizeList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
return String(value || '')
return String(value || "")
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
@@ -196,24 +198,31 @@ function normalizeCustomRules(input) {
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',
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)),
networks: normalizeList(rule.networks).filter((network) =>
["tcp", "udp"].includes(network),
),
}));
}
async function applySelectedServer(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) {
throw new Error('Сначала загрузите подписку');
throw new Error("Сначала загрузите подписку");
}
const customRules = readJson(settings.customRulesPath, []);
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
const generated = buildGatewayConfig(
{ ...cached.config, customRules },
selectedTag,
);
writeSingboxConfig(generated);
await startSingbox();
@@ -227,10 +236,10 @@ async function applySelectedServer(selectedTag) {
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',
"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)) {
@@ -243,51 +252,57 @@ function handleLogsStream(req, res) {
logSubscribers.add(subscriber);
const keepalive = setInterval(() => {
try { res.write(': ping\n\n'); } catch {}
try {
res.write(": ping\n\n");
} catch {}
}, 15000);
req.on('close', () => {
req.on("close", () => {
clearInterval(keepalive);
logSubscribers.delete(subscriber);
});
}
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
if (req.method === "GET" && req.url === "/api/state") {
return sendJson(res, 200, publicState());
}
if (req.method === 'GET' && req.url === '/api/config') {
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') {
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') {
if (req.method === "GET" && req.url === "/api/logs/stream") {
return handleLogsStream(req, res);
}
if (req.method === 'GET' && req.url === '/api/rules') {
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') {
if (req.method === "PUT" && req.url === "/api/rules") {
const body = await readBody(req);
const rules = normalizeCustomRules(body.rules);
writeJson(settings.customRulesPath, rules);
return sendJson(res, 200, { success: true, rules });
}
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
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 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 });
@@ -304,8 +319,9 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed });
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
if (fs.existsSync(settings.subscriptionCachePath)) fs.rmSync(settings.subscriptionCachePath);
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;
@@ -316,14 +332,18 @@ async function handleApi(req, res) {
writeJson(settings.statePath, prevState);
await stopSingbox();
removeSingboxConfig();
pushLog('info', 'Подписка удалена, sing-box остановлен');
pushLog("info", "Подписка удалена, sing-box остановлен");
return sendJson(res, 200, { success: true });
}
if (req.method === 'POST' && req.url === '/api/apply') {
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 обязателен' });
const selectedTag = String(body.selectedTag || "").trim();
if (!selectedTag)
return sendJson(res, 400, {
success: false,
error: "selectedTag обязателен",
});
await applySelectedServer(selectedTag);
return sendJson(res, 200, {
@@ -334,90 +354,103 @@ async function handleApi(req, res) {
});
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
if (req.method === "POST" && req.url === "/api/singbox/stop") {
await stopSingbox();
pushLog('info', 'sing-box остановлен пользователем');
pushLog("info", "sing-box остановлен пользователем");
return sendJson(res, 200, { success: true, singboxRunning: false });
}
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
if (req.method === "POST" && req.url === "/api/singbox/restart") {
if (!fs.existsSync(settings.configPath)) {
return sendJson(res, 400, { success: false, error: 'Конфиг отсутствует — сначала примените сервер' });
return sendJson(res, 400, {
success: false,
error: "Конфиг отсутствует — сначала примените сервер",
});
}
await startSingbox();
pushLog('info', 'sing-box перезапущен пользователем');
return sendJson(res, 200, { success: true, singboxRunning: Boolean(singboxProcess) });
pushLog("info", "sing-box перезапущен пользователем");
return sendJson(res, 200, {
success: true,
singboxRunning: Boolean(singboxProcess),
});
}
if (req.method === 'POST' && req.url === '/api/singbox/clear') {
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 удалён, процесс остановлен');
pushLog("info", "Конфиг sing-box удалён, процесс остановлен");
return sendJson(res, 200, { success: true, singboxRunning: false });
}
return sendJson(res, 404, { success: false, error: 'Не найдено' });
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',
".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 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');
return res.end("Forbidden");
}
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath
: path.join(settings.distDir, 'index.html');
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' });
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/')) {
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) });
console.error("[control] request failed", error);
return sendJson(res, 500, {
success: false,
error: error.message || String(error),
});
}
});
process.on('SIGTERM', async () => {
process.on("SIGTERM", async () => {
await stopSingbox();
process.exit(0);
});
process.on('SIGINT', async () => {
process.on("SIGINT", async () => {
await stopSingbox();
process.exit(0);
});
await startSingbox().catch((error) => {
console.warn(`[control] sing-box не запущен: ${error.message}`);
pushLog('error', `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}`);
});