diff --git a/README.md b/README.md index 59f191d..3a2f3f7 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,17 @@ UI будет доступен на хосте по `http://:3456` ## REST API -| Метод | Путь | Назначение | -| --- | --- | --- | -| GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription | -| GET | `/api/config` | текущий sing-box config | -| GET | `/api/logs` | последние 200 строк логов | -| GET | `/api/logs/stream` | SSE-поток логов sing-box | -| GET / PUT | `/api/rules` | список кастомных правил | -| POST | `/api/subscription/fetch` | загрузить подписку | -| DELETE | `/api/subscription` | удалить подписку, остановить sing-box | -| POST | `/api/apply` | применить выбранный сервер | -| POST | `/api/singbox/{stop,restart,clear}` | управление процессом | +| Метод | Путь | Назначение | +| --------- | ----------------------------------- | ------------------------------------------------------------------ | +| GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription | +| GET | `/api/config` | текущий sing-box config | +| GET | `/api/logs` | последние 200 строк логов | +| GET | `/api/logs/stream` | SSE-поток логов sing-box | +| GET / PUT | `/api/rules` | список кастомных правил | +| POST | `/api/subscription/fetch` | загрузить подписку | +| DELETE | `/api/subscription` | удалить подписку, остановить sing-box | +| POST | `/api/apply` | применить выбранный сервер | +| POST | `/api/singbox/{stop,restart,clear}` | управление процессом | ## Важные ограничения diff --git a/src/server/config.js b/src/server/config.js index f4c3408..4f98e5f 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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 = { port: Number(process.env.PORT || 3456), proxyPort: Number(process.env.PROXY_PORT || 8080), 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, - distDir: process.env.DIST_DIR || '/app/dist', - configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json', - cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db', - statePath: path.join(dataDir, 'state.json'), - customRulesPath: path.join(dataDir, 'custom-rules.json'), - subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'), - hwidPath: path.join(dataDir, 'hwid'), - routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false', - logLevel: process.env.LOG_LEVEL || 'info', - appName: 'VPN Proxy Gateway', + distDir: process.env.DIST_DIR || "/app/dist", + configPath: process.env.SING_BOX_CONFIG || "/etc/sing-box/config.json", + cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", + statePath: path.join(dataDir, "state.json"), + customRulesPath: path.join(dataDir, "custom-rules.json"), + subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), + hwidPath: path.join(dataDir, "hwid"), + routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", + logLevel: process.env.LOG_LEVEL || "info", + appName: "VPN Proxy Gateway", }; diff --git a/src/server/index.js b/src/server/index.js index 52681e9..172b6ed 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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}`); }); diff --git a/src/server/singbox.js b/src/server/singbox.js index a045c87..8eb816c 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -1,21 +1,36 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { settings } from './config.js'; +import fs from "node:fs"; +import path from "node:path"; +import { settings } from "./config.js"; -const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); -const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']); +const PROXY_TYPES = new Set([ + "vless", + "vmess", + "trojan", + "shadowsocks", + "hysteria2", +]); +const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]); function clone(value) { return JSON.parse(JSON.stringify(value)); } function findOutbound(subscriptionConfig, selectedTag) { - const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : []; - const exact = outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type)); + const outbounds = Array.isArray(subscriptionConfig?.outbounds) + ? subscriptionConfig.outbounds + : []; + const exact = outbounds.find( + (outbound) => + outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type), + ); if (exact) return exact; - const trimmedTag = String(selectedTag || '').trim(); - return outbounds.find((outbound) => String(outbound.tag || '').trim() === trimmedTag && PROXY_TYPES.has(outbound.type)); + const trimmedTag = String(selectedTag || "").trim(); + return outbounds.find( + (outbound) => + String(outbound.tag || "").trim() === trimmedTag && + PROXY_TYPES.has(outbound.type), + ); } function ruleSets() { @@ -23,18 +38,18 @@ function ruleSets() { return [ { - type: 'remote', - tag: 'geoip-ru', - format: 'binary', - url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', - download_detour: 'direct', + type: "remote", + tag: "geoip-ru", + format: "binary", + url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs", + download_detour: "direct", }, { - type: 'remote', - tag: 'geosite-category-ru', - format: 'binary', - url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', - download_detour: 'direct', + type: "remote", + tag: "geosite-category-ru", + format: "binary", + url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs", + download_detour: "direct", }, ]; } @@ -43,7 +58,7 @@ function uniqueClean(values) { return Array.from( new Set( (Array.isArray(values) ? values : []) - .map((value) => String(value || '').trim()) + .map((value) => String(value || "").trim()) .filter(Boolean), ), ); @@ -65,7 +80,9 @@ function toSingboxRule(customRule, vpnTag) { const domainKeywords = uniqueClean(customRule.domainKeywords); const ipCidrs = uniqueClean(customRule.ipCidrs); 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 (domainSuffixes.length) rule.domain_suffix = domainSuffixes; @@ -85,7 +102,7 @@ function toSingboxRule(customRule, vpnTag) { return null; } - rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound; + rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound; return rule; } @@ -99,7 +116,7 @@ function routeRules(customRules, vpnTag) { const rules = [ { ip_is_private: true, - outbound: 'direct', + outbound: "direct", }, ]; @@ -107,8 +124,8 @@ function routeRules(customRules, vpnTag) { if (settings.routingRuDirect) { rules.push({ - rule_set: ['geoip-ru', 'geosite-category-ru'], - outbound: 'direct', + rule_set: ["geoip-ru", "geosite-category-ru"], + outbound: "direct", }); } @@ -122,9 +139,9 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { } const vpnOutbound = clone(selectedOutbound); - if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; - if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { - vpnOutbound.packet_encoding = 'xudp'; + if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out"; + if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) { + vpnOutbound.packet_encoding = "xudp"; } return { @@ -143,16 +160,16 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { }, inbounds: [ { - type: 'tproxy', - tag: 'tproxy-in', - listen: '::', + type: "tproxy", + tag: "tproxy-in", + listen: "::", listen_port: settings.tproxyPort, sniff: true, sniff_override_destination: true, }, { - type: 'mixed', - tag: 'mixed-in', + type: "mixed", + tag: "mixed-in", listen: settings.bindIp, listen_port: settings.proxyPort, sniff: true, @@ -161,8 +178,8 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { ], outbounds: [ vpnOutbound, - { type: 'direct', tag: 'direct' }, - { type: 'block', tag: 'block' }, + { type: "direct", tag: "direct" }, + { type: "block", tag: "block" }, ], route: { rule_set: ruleSets(), @@ -175,13 +192,17 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { export function writeSingboxConfig(config) { 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() { if (!fs.existsSync(settings.configPath)) return null; try { - return JSON.parse(fs.readFileSync(settings.configPath, 'utf8')); + return JSON.parse(fs.readFileSync(settings.configPath, "utf8")); } catch { return null; } diff --git a/src/web/api.js b/src/web/api.js index c35347c..557e17c 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -2,32 +2,43 @@ async function request(url, options = {}) { const response = await fetch(url, { ...options, headers: { - 'content-type': 'application/json', + "content-type": "application/json", ...(options.headers || {}), }, }); const data = await response.json().catch(() => ({})); 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; } export const api = { - state: () => request('/api/state'), - config: () => request('/api/config'), + state: () => request("/api/state"), + config: () => request("/api/config"), rules: { - get: () => request('/api/rules'), - save: (rules) => request('/api/rules', { method: 'PUT', body: JSON.stringify({ rules }) }), + get: () => request("/api/rules"), + save: (rules) => + request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }), }, subscription: { - fetch: (url) => request('/api/subscription/fetch', { method: 'POST', body: JSON.stringify({ url }) }), - forget: () => request('/api/subscription', { method: 'DELETE' }), + fetch: (url) => + 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: { - stop: () => request('/api/singbox/stop', { method: 'POST' }), - restart: () => request('/api/singbox/restart', { method: 'POST' }), - clear: () => request('/api/singbox/clear', { method: 'POST' }), + stop: () => request("/api/singbox/stop", { method: "POST" }), + restart: () => request("/api/singbox/restart", { method: "POST" }), + clear: () => request("/api/singbox/clear", { method: "POST" }), }, }; diff --git a/src/web/templates/ruleTemplates.js b/src/web/templates/ruleTemplates.js index 7380313..44ace12 100644 --- a/src/web/templates/ruleTemplates.js +++ b/src/web/templates/ruleTemplates.js @@ -9,7 +9,7 @@ function id(prefix) { function template(name, outbound, fields) { return { - id: id('tpl'), + id: id("tpl"), name, enabled: true, outbound, @@ -25,61 +25,103 @@ function template(name, outbound, fields) { export const ruleTemplates = [ { - key: 'lol-direct', - label: 'League of Legends → direct', - description: 'Riot/LoL домены и порты — играть напрямую без VPN.', + key: "lol-direct", + label: "League of Legends → direct", + description: "Riot/LoL домены и порты — играть напрямую без VPN.", build: () => - template('League of Legends', 'direct', { - domainSuffixes: ['leagueoflegends.com', 'riotgames.com', 'riotcdn.net', 'dyn.riotcdn.net'], - ports: ['5000', '5223', '5222', '8088'], + template("League of Legends", "direct", { + domainSuffixes: [ + "leagueoflegends.com", + "riotgames.com", + "riotcdn.net", + "dyn.riotcdn.net", + ], + ports: ["5000", "5223", "5222", "8088"], }), }, { - key: 'discord-direct', - label: 'Discord/Vesktop → direct', - description: 'Discord voice/video и WebSocket напрямую.', + key: "discord-direct", + label: "Discord/Vesktop → direct", + description: "Discord voice/video и WebSocket напрямую.", build: () => - template('Discord', 'direct', { - domainSuffixes: ['discord.com', 'discord.gg', 'discord.media', 'discordapp.com', 'discordapp.net'], - ports: ['50000-65535'], - networks: ['udp'], + template("Discord", "direct", { + domainSuffixes: [ + "discord.com", + "discord.gg", + "discord.media", + "discordapp.com", + "discordapp.net", + ], + ports: ["50000-65535"], + networks: ["udp"], }), }, { - key: 'telegram-vpn', - label: 'Telegram → VPN', - description: 'Telegram через выбранный VPN outbound.', + key: "telegram-vpn", + label: "Telegram → VPN", + description: "Telegram через выбранный VPN outbound.", build: () => - template('Telegram', 'vpn', { - domainSuffixes: ['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'], + template("Telegram", "vpn", { + domainSuffixes: [ + "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', - label: 'YouTube → VPN', - description: 'YouTube/Google Video через VPN.', + key: "youtube-vpn", + label: "YouTube → VPN", + description: "YouTube/Google Video через VPN.", build: () => - template('YouTube', 'vpn', { - domainSuffixes: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com', 'youtube-nocookie.com'], + template("YouTube", "vpn", { + domainSuffixes: [ + "youtube.com", + "youtu.be", + "ytimg.com", + "googlevideo.com", + "youtube-nocookie.com", + ], }), }, { - key: 'steam-direct', - label: 'Steam → direct', - description: 'Загрузка/обновления Steam напрямую.', + key: "steam-direct", + label: "Steam → direct", + description: "Загрузка/обновления Steam напрямую.", build: () => - template('Steam', 'direct', { - domainSuffixes: ['steampowered.com', 'steamcontent.com', 'steamcommunity.com', 'steamserver.net', 'cm.steampowered.com'], + template("Steam", "direct", { + domainSuffixes: [ + "steampowered.com", + "steamcontent.com", + "steamcommunity.com", + "steamserver.net", + "cm.steampowered.com", + ], }), }, { - key: 'ads-block', - label: 'Реклама → block', - description: 'Базовый набор рекламных доменов — заблокировать.', + key: "ads-block", + label: "Реклама → block", + description: "Базовый набор рекламных доменов — заблокировать.", build: () => - template('Реклама (block)', 'block', { - domainSuffixes: ['doubleclick.net', 'googlesyndication.com', 'googleadservices.com', 'adservice.google.com', 'adnxs.com'], + template("Реклама (block)", "block", { + domainSuffixes: [ + "doubleclick.net", + "googlesyndication.com", + "googleadservices.com", + "adservice.google.com", + "adnxs.com", + ], }), }, ]; diff --git a/src/web/utils/format.js b/src/web/utils/format.js index 80fd8dc..3152be1 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -1,6 +1,6 @@ export function formatBytes(value) { - if (!value) return '0 Б'; - const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; + if (!value) return "0 Б"; + const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; let size = value; let index = 0; while (size >= 1024 && index < units.length - 1) { @@ -11,9 +11,9 @@ export function formatBytes(value) { } export function formatRelative(iso) { - if (!iso) return ''; + if (!iso) return ""; 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 sec = Math.floor(diff / 1000); if (sec < 60) return `${sec} с назад`; @@ -26,6 +26,6 @@ export function formatRelative(iso) { } export function formatTime(iso) { - if (!iso) return ''; - return new Date(iso).toLocaleTimeString('ru-RU', { hour12: false }); + if (!iso) return ""; + return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false }); } diff --git a/src/web/utils/validation.js b/src/web/utils/validation.js index 72dcc74..5585491 100644 --- a/src/web/utils/validation.js +++ b/src/web/utils/validation.js @@ -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 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) { return (values || []).filter((value) => !isValidCidr(value)); } export function isValidCidr(value) { - const trimmed = String(value || '').trim(); + const trimmed = String(value || "").trim(); if (!trimmed) return false; - const [addr, mask] = trimmed.split('/'); + const [addr, mask] = trimmed.split("/"); if (!addr) return false; if (IPV4.test(addr)) { @@ -19,7 +21,7 @@ export function isValidCidr(value) { const m = Number(mask); 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; const m = Number(mask); return Number.isInteger(m) && m >= 0 && m <= 128;