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

@@ -33,7 +33,7 @@ UI будет доступен на хосте по `http://<gateway-host>:3456`
## REST API ## REST API
| Метод | Путь | Назначение | | Метод | Путь | Назначение |
| --- | --- | --- | | --------- | ----------------------------------- | ------------------------------------------------------------------ |
| GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription | | GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription |
| GET | `/api/config` | текущий sing-box config | | GET | `/api/config` | текущий sing-box config |
| GET | `/api/logs` | последние 200 строк логов | | GET | `/api/logs` | последние 200 строк логов |

View File

@@ -1,21 +1,21 @@
import path from 'node:path'; import path from "node:path";
const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy'); const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
export const settings = { export const settings = {
port: Number(process.env.PORT || 3456), port: Number(process.env.PORT || 3456),
proxyPort: Number(process.env.PROXY_PORT || 8080), proxyPort: Number(process.env.PROXY_PORT || 8080),
tproxyPort: Number(process.env.TPROXY_PORT || 7895), tproxyPort: Number(process.env.TPROXY_PORT || 7895),
bindIp: process.env.PROXY_BIND_IP || '127.0.0.1', bindIp: process.env.PROXY_BIND_IP || "127.0.0.1",
dataDir, dataDir,
distDir: process.env.DIST_DIR || '/app/dist', distDir: process.env.DIST_DIR || "/app/dist",
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json', configPath: process.env.SING_BOX_CONFIG || "/etc/sing-box/config.json",
cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db', cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
statePath: path.join(dataDir, 'state.json'), statePath: path.join(dataDir, "state.json"),
customRulesPath: path.join(dataDir, 'custom-rules.json'), customRulesPath: path.join(dataDir, "custom-rules.json"),
subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
hwidPath: path.join(dataDir, 'hwid'), hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false', routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
logLevel: process.env.LOG_LEVEL || 'info', logLevel: process.env.LOG_LEVEL || "info",
appName: 'VPN Proxy Gateway', appName: "VPN Proxy Gateway",
}; };

View File

@@ -1,15 +1,15 @@
import http from 'node:http'; import http from "node:http";
import fs from 'node:fs'; import fs from "node:fs";
import path from 'node:path'; import path from "node:path";
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from "node:child_process";
import { settings } from './config.js'; import { settings } from "./config.js";
import { fetchSubscription } from './subscription.js'; import { fetchSubscription } from "./subscription.js";
import { import {
buildGatewayConfig, buildGatewayConfig,
writeSingboxConfig, writeSingboxConfig,
readSingboxConfig, readSingboxConfig,
removeSingboxConfig, removeSingboxConfig,
} from './singbox.js'; } from "./singbox.js";
fs.mkdirSync(settings.dataDir, { recursive: true }); fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -31,31 +31,31 @@ function pushLog(level, line) {
} }
function captureStream(stream, level) { function captureStream(stream, level) {
let remainder = ''; let remainder = "";
stream.setEncoding('utf8'); stream.setEncoding("utf8");
stream.on('data', (chunk) => { stream.on("data", (chunk) => {
const data = remainder + chunk; const data = remainder + chunk;
const lines = data.split(/\r?\n/); const lines = data.split(/\r?\n/);
remainder = lines.pop() || ''; remainder = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
if (!line) continue; if (!line) continue;
process.stdout.write(`[sing-box:${level}] ${line}\n`); process.stdout.write(`[sing-box:${level}] ${line}\n`);
pushLog(level, line); pushLog(level, line);
} }
}); });
stream.on('end', () => { stream.on("end", () => {
if (remainder) { if (remainder) {
process.stdout.write(`[sing-box:${level}] ${remainder}\n`); process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
pushLog(level, remainder); pushLog(level, remainder);
} }
remainder = ''; remainder = "";
}); });
} }
function readJson(filePath, fallback) { function readJson(filePath, fallback) {
try { try {
if (!fs.existsSync(filePath)) return fallback; if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8')); return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch { } catch {
return fallback; return fallback;
} }
@@ -63,11 +63,11 @@ function readJson(filePath, fallback) {
function writeJson(filePath, value) { function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true }); 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) { function maskSubscriptionUrl(url) {
if (!url) return ''; if (!url) return "";
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return `${parsed.hostname}/...`; return `${parsed.hostname}/...`;
@@ -79,8 +79,8 @@ function maskSubscriptionUrl(url) {
function sendJson(res, statusCode, payload) { function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2); const body = JSON.stringify(payload, null, 2);
res.writeHead(statusCode, { res.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8', "content-type": "application/json; charset=utf-8",
'content-length': Buffer.byteLength(body), "content-length": Buffer.byteLength(body),
}); });
res.end(body); res.end(body);
} }
@@ -88,26 +88,28 @@ function sendJson(res, statusCode, payload) {
function readBody(req) { function readBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
req.on('data', (chunk) => chunks.push(chunk)); req.on("data", (chunk) => chunks.push(chunk));
req.on('end', () => { req.on("end", () => {
if (!chunks.length) return resolve({}); if (!chunks.length) return resolve({});
try { try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch { } catch {
reject(new Error('Невалидный JSON в теле запроса')); reject(new Error("Невалидный JSON в теле запроса"));
} }
}); });
req.on('error', reject); req.on("error", reject);
}); });
} }
function checkSingboxConfig() { function checkSingboxConfig() {
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], { const result = spawnSync("sing-box", ["check", "-c", settings.configPath], {
encoding: 'utf8', encoding: "utf8",
}); });
if (result.status !== 0) { 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; singboxStartedAt = null;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
current.kill('SIGKILL'); current.kill("SIGKILL");
resolve(); resolve();
}, 4000); }, 4000);
current.once('exit', () => { current.once("exit", () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(); resolve();
}); });
current.kill('SIGTERM'); current.kill("SIGTERM");
}); });
} }
@@ -142,17 +144,17 @@ async function startSingbox() {
checkSingboxConfig(); checkSingboxConfig();
await stopSingbox(); await stopSingbox();
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], { singboxProcess = spawn("sing-box", ["run", "-c", settings.configPath], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ["ignore", "pipe", "pipe"],
}); });
singboxStartedAt = new Date().toISOString(); singboxStartedAt = new Date().toISOString();
pushLog('info', `sing-box запущен (pid=${singboxProcess.pid})`); pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);
captureStream(singboxProcess.stdout, 'info'); captureStream(singboxProcess.stdout, "info");
captureStream(singboxProcess.stderr, 'error'); captureStream(singboxProcess.stderr, "error");
singboxProcess.once('exit', (code, signal) => { singboxProcess.once("exit", (code, signal) => {
pushLog('info', `sing-box завершён: code=${code} signal=${signal}`); pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess = null; singboxProcess = null;
singboxStartedAt = null; singboxStartedAt = null;
}); });
@@ -165,7 +167,7 @@ function publicState() {
const customRules = readJson(settings.customRulesPath, []); const customRules = readJson(settings.customRulesPath, []);
const { subscriptionUrl, ...rest } = state; const { subscriptionUrl, ...rest } = state;
return { return {
mode: 'gateway', mode: "gateway",
port: settings.port, port: settings.port,
proxyPort: settings.proxyPort, proxyPort: settings.proxyPort,
tproxyPort: settings.tproxyPort, tproxyPort: settings.tproxyPort,
@@ -182,9 +184,9 @@ function publicState() {
function normalizeList(value) { function normalizeList(value) {
if (Array.isArray(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|,/) .split(/\r?\n|,/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
@@ -196,24 +198,31 @@ function normalizeCustomRules(input) {
id: String(rule.id || `rule-${Date.now()}-${index}`), id: String(rule.id || `rule-${Date.now()}-${index}`),
name: String(rule.name || `Правило ${index + 1}`).trim(), name: String(rule.name || `Правило ${index + 1}`).trim(),
enabled: rule.enabled !== false, 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), domains: normalizeList(rule.domains),
domainSuffixes: normalizeList(rule.domainSuffixes), domainSuffixes: normalizeList(rule.domainSuffixes),
domainKeywords: normalizeList(rule.domainKeywords), domainKeywords: normalizeList(rule.domainKeywords),
ipCidrs: normalizeList(rule.ipCidrs), ipCidrs: normalizeList(rule.ipCidrs),
ports: normalizeList(rule.ports), 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) { async function applySelectedServer(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null); const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) { if (!cached?.config) {
throw new Error('Сначала загрузите подписку'); throw new Error("Сначала загрузите подписку");
} }
const customRules = readJson(settings.customRulesPath, []); const customRules = readJson(settings.customRulesPath, []);
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag); const generated = buildGatewayConfig(
{ ...cached.config, customRules },
selectedTag,
);
writeSingboxConfig(generated); writeSingboxConfig(generated);
await startSingbox(); await startSingbox();
@@ -227,10 +236,10 @@ async function applySelectedServer(selectedTag) {
function handleLogsStream(req, res) { function handleLogsStream(req, res) {
res.writeHead(200, { res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8', "content-type": "text/event-stream; charset=utf-8",
'cache-control': 'no-cache, no-transform', "cache-control": "no-cache, no-transform",
connection: 'keep-alive', connection: "keep-alive",
'x-accel-buffering': 'no', "x-accel-buffering": "no",
}); });
for (const entry of logBuffer.slice(-200)) { for (const entry of logBuffer.slice(-200)) {
@@ -243,51 +252,57 @@ function handleLogsStream(req, res) {
logSubscribers.add(subscriber); logSubscribers.add(subscriber);
const keepalive = setInterval(() => { const keepalive = setInterval(() => {
try { res.write(': ping\n\n'); } catch {} try {
res.write(": ping\n\n");
} catch {}
}, 15000); }, 15000);
req.on('close', () => { req.on("close", () => {
clearInterval(keepalive); clearInterval(keepalive);
logSubscribers.delete(subscriber); logSubscribers.delete(subscriber);
}); });
} }
async function handleApi(req, res) { 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()); return sendJson(res, 200, publicState());
} }
if (req.method === 'GET' && req.url === '/api/config') { if (req.method === "GET" && req.url === "/api/config") {
const config = readSingboxConfig(); const config = readSingboxConfig();
return sendJson(res, 200, { success: true, config }); 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) }); 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); return handleLogsStream(req, res);
} }
if (req.method === 'GET' && req.url === '/api/rules') { if (req.method === "GET" && req.url === "/api/rules") {
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
rules: readJson(settings.customRulesPath, []), 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 body = await readBody(req);
const rules = normalizeCustomRules(body.rules); const rules = normalizeCustomRules(body.rules);
writeJson(settings.customRulesPath, rules); writeJson(settings.customRulesPath, rules);
return sendJson(res, 200, { success: true, 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 body = await readBody(req);
const url = String(body.url || '').trim(); const url = String(body.url || "").trim();
if (!url) return sendJson(res, 400, { success: false, error: 'Укажите subscription URL' }); if (!url)
return sendJson(res, 400, {
success: false,
error: "Укажите subscription URL",
});
const parsed = await fetchSubscription(url); const parsed = await fetchSubscription(url);
writeJson(settings.subscriptionCachePath, { url, ...parsed }); writeJson(settings.subscriptionCachePath, { url, ...parsed });
@@ -304,8 +319,9 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed }); return sendJson(res, 200, { success: true, ...parsed });
} }
if (req.method === 'DELETE' && req.url === '/api/subscription') { if (req.method === "DELETE" && req.url === "/api/subscription") {
if (fs.existsSync(settings.subscriptionCachePath)) fs.rmSync(settings.subscriptionCachePath); if (fs.existsSync(settings.subscriptionCachePath))
fs.rmSync(settings.subscriptionCachePath);
const prevState = readJson(settings.statePath, {}); const prevState = readJson(settings.statePath, {});
delete prevState.subscriptionUrl; delete prevState.subscriptionUrl;
delete prevState.servers; delete prevState.servers;
@@ -316,14 +332,18 @@ async function handleApi(req, res) {
writeJson(settings.statePath, prevState); writeJson(settings.statePath, prevState);
await stopSingbox(); await stopSingbox();
removeSingboxConfig(); removeSingboxConfig();
pushLog('info', 'Подписка удалена, sing-box остановлен'); pushLog("info", "Подписка удалена, sing-box остановлен");
return sendJson(res, 200, { success: true }); 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 body = await readBody(req);
const selectedTag = String(body.selectedTag || '').trim(); const selectedTag = String(body.selectedTag || "").trim();
if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag обязателен' }); if (!selectedTag)
return sendJson(res, 400, {
success: false,
error: "selectedTag обязателен",
});
await applySelectedServer(selectedTag); await applySelectedServer(selectedTag);
return sendJson(res, 200, { 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(); await stopSingbox();
pushLog('info', 'sing-box остановлен пользователем'); pushLog("info", "sing-box остановлен пользователем");
return sendJson(res, 200, { success: true, singboxRunning: false }); 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)) { if (!fs.existsSync(settings.configPath)) {
return sendJson(res, 400, { success: false, error: 'Конфиг отсутствует — сначала примените сервер' }); return sendJson(res, 400, {
success: false,
error: "Конфиг отсутствует — сначала примените сервер",
});
} }
await startSingbox(); await startSingbox();
pushLog('info', 'sing-box перезапущен пользователем'); pushLog("info", "sing-box перезапущен пользователем");
return sendJson(res, 200, { success: true, singboxRunning: Boolean(singboxProcess) }); 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(); await stopSingbox();
removeSingboxConfig(); removeSingboxConfig();
const prevState = readJson(settings.statePath, {}); const prevState = readJson(settings.statePath, {});
delete prevState.selectedTag; delete prevState.selectedTag;
delete prevState.appliedAt; delete prevState.appliedAt;
writeJson(settings.statePath, prevState); writeJson(settings.statePath, prevState);
pushLog('info', 'Конфиг sing-box удалён, процесс остановлен'); pushLog("info", "Конфиг sing-box удалён, процесс остановлен");
return sendJson(res, 200, { success: true, singboxRunning: false }); return sendJson(res, 200, { success: true, singboxRunning: false });
} }
return sendJson(res, 404, { success: false, error: 'Не найдено' }); return sendJson(res, 404, { success: false, error: "Не найдено" });
} }
const mime = { const mime = {
'.html': 'text/html; charset=utf-8', ".html": "text/html; charset=utf-8",
'.js': 'text/javascript; charset=utf-8', ".js": "text/javascript; charset=utf-8",
'.css': 'text/css; charset=utf-8', ".css": "text/css; charset=utf-8",
'.svg': 'image/svg+xml', ".svg": "image/svg+xml",
'.json': 'application/json; charset=utf-8', ".json": "application/json; charset=utf-8",
}; };
function serveStatic(req, res) { function serveStatic(req, res) {
const requestPath = new URL(req.url, `http://localhost:${settings.port}`).pathname; const requestPath = new URL(req.url, `http://localhost:${settings.port}`)
const cleanPath = requestPath === '/' ? '/index.html' : requestPath; .pathname;
const cleanPath = requestPath === "/" ? "/index.html" : requestPath;
const filePath = path.resolve(settings.distDir, `.${cleanPath}`); const filePath = path.resolve(settings.distDir, `.${cleanPath}`);
const distRoot = path.resolve(settings.distDir); const distRoot = path.resolve(settings.distDir);
if (!filePath.startsWith(distRoot)) { if (!filePath.startsWith(distRoot)) {
res.writeHead(403); res.writeHead(403);
return res.end('Forbidden'); return res.end("Forbidden");
} }
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() const finalPath =
fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath ? filePath
: path.join(settings.distDir, 'index.html'); : path.join(settings.distDir, "index.html");
const ext = path.extname(finalPath); 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); fs.createReadStream(finalPath).pipe(res);
} }
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
if (req.url?.startsWith('/api/')) { if (req.url?.startsWith("/api/")) {
return await handleApi(req, res); return await handleApi(req, res);
} }
return serveStatic(req, res); return serveStatic(req, res);
} catch (error) { } catch (error) {
console.error('[control] request failed', error); console.error("[control] request failed", error);
return sendJson(res, 500, { success: false, error: error.message || String(error) }); return sendJson(res, 500, {
success: false,
error: error.message || String(error),
});
} }
}); });
process.on('SIGTERM', async () => { process.on("SIGTERM", async () => {
await stopSingbox(); await stopSingbox();
process.exit(0); process.exit(0);
}); });
process.on('SIGINT', async () => { process.on("SIGINT", async () => {
await stopSingbox(); await stopSingbox();
process.exit(0); process.exit(0);
}); });
await startSingbox().catch((error) => { await startSingbox().catch((error) => {
console.warn(`[control] sing-box не запущен: ${error.message}`); 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}`); console.log(`[control] gateway UI слушает :${settings.port}`);
}); });

View File

@@ -1,21 +1,36 @@
import fs from 'node:fs'; import fs from "node:fs";
import path from 'node:path'; import path from "node:path";
import { settings } from './config.js'; import { settings } from "./config.js";
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set([
const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']); "vless",
"vmess",
"trojan",
"shadowsocks",
"hysteria2",
]);
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
function clone(value) { function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function findOutbound(subscriptionConfig, selectedTag) { function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : []; const outbounds = Array.isArray(subscriptionConfig?.outbounds)
const exact = outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type)); ? subscriptionConfig.outbounds
: [];
const exact = outbounds.find(
(outbound) =>
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
);
if (exact) return exact; if (exact) return exact;
const trimmedTag = String(selectedTag || '').trim(); const trimmedTag = String(selectedTag || "").trim();
return outbounds.find((outbound) => String(outbound.tag || '').trim() === trimmedTag && PROXY_TYPES.has(outbound.type)); return outbounds.find(
(outbound) =>
String(outbound.tag || "").trim() === trimmedTag &&
PROXY_TYPES.has(outbound.type),
);
} }
function ruleSets() { function ruleSets() {
@@ -23,18 +38,18 @@ function ruleSets() {
return [ return [
{ {
type: 'remote', type: "remote",
tag: 'geoip-ru', tag: "geoip-ru",
format: 'binary', format: "binary",
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
download_detour: 'direct', download_detour: "direct",
}, },
{ {
type: 'remote', type: "remote",
tag: 'geosite-category-ru', tag: "geosite-category-ru",
format: 'binary', format: "binary",
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
download_detour: 'direct', download_detour: "direct",
}, },
]; ];
} }
@@ -43,7 +58,7 @@ function uniqueClean(values) {
return Array.from( return Array.from(
new Set( new Set(
(Array.isArray(values) ? values : []) (Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim()) .map((value) => String(value || "").trim())
.filter(Boolean), .filter(Boolean),
), ),
); );
@@ -65,7 +80,9 @@ function toSingboxRule(customRule, vpnTag) {
const domainKeywords = uniqueClean(customRule.domainKeywords); const domainKeywords = uniqueClean(customRule.domainKeywords);
const ipCidrs = uniqueClean(customRule.ipCidrs); const ipCidrs = uniqueClean(customRule.ipCidrs);
const ports = parsePorts(customRule.ports); const ports = parsePorts(customRule.ports);
const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network)); const networks = uniqueClean(customRule.networks).filter((network) =>
["tcp", "udp"].includes(network),
);
if (domains.length) rule.domain = domains; if (domains.length) rule.domain = domains;
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes; if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
@@ -85,7 +102,7 @@ function toSingboxRule(customRule, vpnTag) {
return null; return null;
} }
rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound; rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
return rule; return rule;
} }
@@ -99,7 +116,7 @@ function routeRules(customRules, vpnTag) {
const rules = [ const rules = [
{ {
ip_is_private: true, ip_is_private: true,
outbound: 'direct', outbound: "direct",
}, },
]; ];
@@ -107,8 +124,8 @@ function routeRules(customRules, vpnTag) {
if (settings.routingRuDirect) { if (settings.routingRuDirect) {
rules.push({ rules.push({
rule_set: ['geoip-ru', 'geosite-category-ru'], rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: 'direct', outbound: "direct",
}); });
} }
@@ -122,9 +139,9 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
} }
const vpnOutbound = clone(selectedOutbound); const vpnOutbound = clone(selectedOutbound);
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp'; vpnOutbound.packet_encoding = "xudp";
} }
return { return {
@@ -143,16 +160,16 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
}, },
inbounds: [ inbounds: [
{ {
type: 'tproxy', type: "tproxy",
tag: 'tproxy-in', tag: "tproxy-in",
listen: '::', listen: "::",
listen_port: settings.tproxyPort, listen_port: settings.tproxyPort,
sniff: true, sniff: true,
sniff_override_destination: true, sniff_override_destination: true,
}, },
{ {
type: 'mixed', type: "mixed",
tag: 'mixed-in', tag: "mixed-in",
listen: settings.bindIp, listen: settings.bindIp,
listen_port: settings.proxyPort, listen_port: settings.proxyPort,
sniff: true, sniff: true,
@@ -161,8 +178,8 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
], ],
outbounds: [ outbounds: [
vpnOutbound, vpnOutbound,
{ type: 'direct', tag: 'direct' }, { type: "direct", tag: "direct" },
{ type: 'block', tag: 'block' }, { type: "block", tag: "block" },
], ],
route: { route: {
rule_set: ruleSets(), rule_set: ruleSets(),
@@ -175,13 +192,17 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
export function writeSingboxConfig(config) { export function writeSingboxConfig(config) {
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true }); fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8'); fs.writeFileSync(
settings.configPath,
JSON.stringify(config, null, 2),
"utf8",
);
} }
export function readSingboxConfig() { export function readSingboxConfig() {
if (!fs.existsSync(settings.configPath)) return null; if (!fs.existsSync(settings.configPath)) return null;
try { try {
return JSON.parse(fs.readFileSync(settings.configPath, 'utf8')); return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
} catch { } catch {
return null; return null;
} }

View File

@@ -2,32 +2,43 @@ async function request(url, options = {}) {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers: { headers: {
'content-type': 'application/json', "content-type": "application/json",
...(options.headers || {}), ...(options.headers || {}),
}, },
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok || (data && data.success === false)) { if (!response.ok || (data && data.success === false)) {
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`); throw new Error(
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
);
} }
return data; return data;
} }
export const api = { export const api = {
state: () => request('/api/state'), state: () => request("/api/state"),
config: () => request('/api/config'), config: () => request("/api/config"),
rules: { rules: {
get: () => request('/api/rules'), get: () => request("/api/rules"),
save: (rules) => request('/api/rules', { method: 'PUT', body: JSON.stringify({ rules }) }), save: (rules) =>
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
}, },
subscription: { subscription: {
fetch: (url) => request('/api/subscription/fetch', { method: 'POST', body: JSON.stringify({ url }) }), fetch: (url) =>
forget: () => request('/api/subscription', { method: 'DELETE' }), request("/api/subscription/fetch", {
method: "POST",
body: JSON.stringify({ url }),
}),
forget: () => request("/api/subscription", { method: "DELETE" }),
}, },
apply: (selectedTag) => request('/api/apply', { method: 'POST', body: JSON.stringify({ selectedTag }) }), apply: (selectedTag) =>
request("/api/apply", {
method: "POST",
body: JSON.stringify({ selectedTag }),
}),
singbox: { singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }), stop: () => request("/api/singbox/stop", { method: "POST" }),
restart: () => request('/api/singbox/restart', { method: 'POST' }), restart: () => request("/api/singbox/restart", { method: "POST" }),
clear: () => request('/api/singbox/clear', { method: 'POST' }), clear: () => request("/api/singbox/clear", { method: "POST" }),
}, },
}; };

View File

@@ -9,7 +9,7 @@ function id(prefix) {
function template(name, outbound, fields) { function template(name, outbound, fields) {
return { return {
id: id('tpl'), id: id("tpl"),
name, name,
enabled: true, enabled: true,
outbound, outbound,
@@ -25,61 +25,103 @@ function template(name, outbound, fields) {
export const ruleTemplates = [ export const ruleTemplates = [
{ {
key: 'lol-direct', key: "lol-direct",
label: 'League of Legends → direct', label: "League of Legends → direct",
description: 'Riot/LoL домены и порты — играть напрямую без VPN.', description: "Riot/LoL домены и порты — играть напрямую без VPN.",
build: () => build: () =>
template('League of Legends', 'direct', { template("League of Legends", "direct", {
domainSuffixes: ['leagueoflegends.com', 'riotgames.com', 'riotcdn.net', 'dyn.riotcdn.net'], domainSuffixes: [
ports: ['5000', '5223', '5222', '8088'], "leagueoflegends.com",
"riotgames.com",
"riotcdn.net",
"dyn.riotcdn.net",
],
ports: ["5000", "5223", "5222", "8088"],
}), }),
}, },
{ {
key: 'discord-direct', key: "discord-direct",
label: 'Discord/Vesktop → direct', label: "Discord/Vesktop → direct",
description: 'Discord voice/video и WebSocket напрямую.', description: "Discord voice/video и WebSocket напрямую.",
build: () => build: () =>
template('Discord', 'direct', { template("Discord", "direct", {
domainSuffixes: ['discord.com', 'discord.gg', 'discord.media', 'discordapp.com', 'discordapp.net'], domainSuffixes: [
ports: ['50000-65535'], "discord.com",
networks: ['udp'], "discord.gg",
"discord.media",
"discordapp.com",
"discordapp.net",
],
ports: ["50000-65535"],
networks: ["udp"],
}), }),
}, },
{ {
key: 'telegram-vpn', key: "telegram-vpn",
label: 'Telegram → VPN', label: "Telegram → VPN",
description: 'Telegram через выбранный VPN outbound.', description: "Telegram через выбранный VPN outbound.",
build: () => build: () =>
template('Telegram', 'vpn', { template("Telegram", "vpn", {
domainSuffixes: ['telegram.org', 't.me', 'telegram.me', 'telegra.ph', 'tdesktop.com'], domainSuffixes: [
ipCidrs: ['149.154.160.0/20', '91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.56.0/22'], "telegram.org",
"t.me",
"telegram.me",
"telegra.ph",
"tdesktop.com",
],
ipCidrs: [
"149.154.160.0/20",
"91.108.4.0/22",
"91.108.8.0/22",
"91.108.12.0/22",
"91.108.16.0/22",
"91.108.56.0/22",
],
}), }),
}, },
{ {
key: 'youtube-vpn', key: "youtube-vpn",
label: 'YouTube → VPN', label: "YouTube → VPN",
description: 'YouTube/Google Video через VPN.', description: "YouTube/Google Video через VPN.",
build: () => build: () =>
template('YouTube', 'vpn', { template("YouTube", "vpn", {
domainSuffixes: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com', 'youtube-nocookie.com'], domainSuffixes: [
"youtube.com",
"youtu.be",
"ytimg.com",
"googlevideo.com",
"youtube-nocookie.com",
],
}), }),
}, },
{ {
key: 'steam-direct', key: "steam-direct",
label: 'Steam → direct', label: "Steam → direct",
description: 'Загрузка/обновления Steam напрямую.', description: "Загрузка/обновления Steam напрямую.",
build: () => build: () =>
template('Steam', 'direct', { template("Steam", "direct", {
domainSuffixes: ['steampowered.com', 'steamcontent.com', 'steamcommunity.com', 'steamserver.net', 'cm.steampowered.com'], domainSuffixes: [
"steampowered.com",
"steamcontent.com",
"steamcommunity.com",
"steamserver.net",
"cm.steampowered.com",
],
}), }),
}, },
{ {
key: 'ads-block', key: "ads-block",
label: 'Реклама → block', label: "Реклама → block",
description: 'Базовый набор рекламных доменов — заблокировать.', description: "Базовый набор рекламных доменов — заблокировать.",
build: () => build: () =>
template('Реклама (block)', 'block', { template("Реклама (block)", "block", {
domainSuffixes: ['doubleclick.net', 'googlesyndication.com', 'googleadservices.com', 'adservice.google.com', 'adnxs.com'], domainSuffixes: [
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"adservice.google.com",
"adnxs.com",
],
}), }),
}, },
]; ];

View File

@@ -1,6 +1,6 @@
export function formatBytes(value) { export function formatBytes(value) {
if (!value) return '0 Б'; if (!value) return "0 Б";
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
let size = value; let size = value;
let index = 0; let index = 0;
while (size >= 1024 && index < units.length - 1) { while (size >= 1024 && index < units.length - 1) {
@@ -11,9 +11,9 @@ export function formatBytes(value) {
} }
export function formatRelative(iso) { export function formatRelative(iso) {
if (!iso) return ''; if (!iso) return "";
const ts = new Date(iso).getTime(); const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return ''; if (Number.isNaN(ts)) return "";
const diff = Math.max(0, Date.now() - ts); const diff = Math.max(0, Date.now() - ts);
const sec = Math.floor(diff / 1000); const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec} с назад`; if (sec < 60) return `${sec} с назад`;
@@ -26,6 +26,6 @@ export function formatRelative(iso) {
} }
export function formatTime(iso) { export function formatTime(iso) {
if (!iso) return ''; if (!iso) return "";
return new Date(iso).toLocaleTimeString('ru-RU', { hour12: false }); return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
} }

View File

@@ -1,17 +1,19 @@
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк. // Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
const IPV4 = /^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/; const IPV4 =
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
const IPV6 = /^[0-9a-f:]+$/i; const IPV6 = /^[0-9a-f:]+$/i;
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; const DOMAIN =
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export function invalidCidrs(values) { export function invalidCidrs(values) {
return (values || []).filter((value) => !isValidCidr(value)); return (values || []).filter((value) => !isValidCidr(value));
} }
export function isValidCidr(value) { export function isValidCidr(value) {
const trimmed = String(value || '').trim(); const trimmed = String(value || "").trim();
if (!trimmed) return false; if (!trimmed) return false;
const [addr, mask] = trimmed.split('/'); const [addr, mask] = trimmed.split("/");
if (!addr) return false; if (!addr) return false;
if (IPV4.test(addr)) { if (IPV4.test(addr)) {
@@ -19,7 +21,7 @@ export function isValidCidr(value) {
const m = Number(mask); const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 32; return Number.isInteger(m) && m >= 0 && m <= 32;
} }
if (IPV6.test(addr) && addr.includes(':')) { if (IPV6.test(addr) && addr.includes(":")) {
if (mask === undefined) return true; if (mask === undefined) return true;
const m = Number(mask); const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 128; return Number.isInteger(m) && m >= 0 && m <= 128;