feat: добавлены компоненты для управления конфигурацией и логами

Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации.

Refs: None
This commit is contained in:
2026-05-08 18:23:29 +03:00
parent 7d41dd86e7
commit 8789496ae6
24 changed files with 2987 additions and 364 deletions

View File

@@ -4,12 +4,53 @@ 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 } from './singbox.js';
import {
buildGatewayConfig,
writeSingboxConfig,
readSingboxConfig,
removeSingboxConfig,
} from './singbox.js';
fs.mkdirSync(settings.dataDir, { recursive: true });
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 {}
}
}
function captureStream(stream, level) {
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;
process.stdout.write(`[sing-box:${level}] ${line}\n`);
pushLog(level, line);
}
});
stream.on('end', () => {
if (remainder) {
process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
pushLog(level, remainder);
}
remainder = '';
});
}
function readJson(filePath, fallback) {
try {
@@ -25,6 +66,16 @@ function writeJson(filePath, value) {
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, {
@@ -43,7 +94,7 @@ function readBody(req) {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new Error('Invalid JSON body'));
reject(new Error('Невалидный JSON в теле запроса'));
}
});
req.on('error', reject);
@@ -62,10 +113,14 @@ function checkSingboxConfig() {
function stopSingbox() {
return new Promise((resolve) => {
if (!singboxProcess) return resolve();
if (!singboxProcess) {
singboxStartedAt = null;
return resolve();
}
const current = singboxProcess;
singboxProcess = null;
singboxStartedAt = null;
const timeout = setTimeout(() => {
current.kill('SIGKILL');
@@ -88,13 +143,18 @@ async function startSingbox() {
await stopSingbox();
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
stdio: 'inherit',
stdio: ['ignore', 'pipe', 'pipe'],
});
singboxStartedAt = new Date().toISOString();
pushLog('info', `sing-box запущен (pid=${singboxProcess.pid})`);
captureStream(singboxProcess.stdout, 'info');
captureStream(singboxProcess.stderr, 'error');
singboxProcess.once('exit', (code, signal) => {
console.log(`[control] sing-box exited: code=${code} signal=${signal}`);
if (singboxProcess?.exitCode === code) singboxProcess = null;
pushLog('info', `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess = null;
singboxStartedAt = null;
});
return true;
@@ -103,6 +163,7 @@ async function startSingbox() {
function publicState() {
const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []);
const { subscriptionUrl, ...rest } = state;
return {
mode: 'gateway',
port: settings.port,
@@ -112,8 +173,10 @@ function publicState() {
configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess),
singboxStartedAt,
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl),
customRules,
...state,
...rest,
};
}
@@ -131,7 +194,7 @@ 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 || `Rule ${index + 1}`).trim(),
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),
@@ -143,11 +206,70 @@ function normalizeCustomRules(input) {
}));
}
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, {});
writeJson(settings.statePath, {
...prevState,
selectedTag,
appliedAt: new Date().toISOString(),
});
}
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,
@@ -165,7 +287,7 @@ async function handleApi(req, res) {
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 is required' });
if (!url) return sendJson(res, 400, { success: false, error: 'Укажите subscription URL' });
const parsed = await fetchSubscription(url);
writeJson(settings.subscriptionCachePath, { url, ...parsed });
@@ -182,28 +304,28 @@ 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);
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 || '');
if (!selectedTag.trim()) return sendJson(res, 400, { success: false, error: 'selectedTag is required' });
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) {
return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' });
}
const customRules = readJson(settings.customRulesPath, []);
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
writeSingboxConfig(generated);
await startSingbox();
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
selectedTag,
appliedAt: new Date().toISOString(),
});
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,
@@ -212,7 +334,33 @@ async function handleApi(req, res) {
});
}
return sendJson(res, 404, { success: false, error: 'Not found' });
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 = {
@@ -266,9 +414,10 @@ process.on('SIGINT', async () => {
});
await startSingbox().catch((error) => {
console.warn(`[control] sing-box was not started: ${error.message}`);
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 listening on :${settings.port}`);
console.log(`[control] gateway UI слушает :${settings.port}`);
});