style: исправлены стили и форматирование кода
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
This commit is contained in:
@@ -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 строк логов |
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user