feat: добавлены компоненты для управления конфигурацией и логами
Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации. Refs: None
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user